actionmcp 0.52.2 → 0.53.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 +2 -0
- data/app/controllers/action_mcp/oauth/endpoints_controller.rb +264 -0
- data/app/controllers/action_mcp/oauth/metadata_controller.rb +129 -0
- data/app/models/action_mcp/session.rb +99 -0
- data/config/routes.rb +10 -0
- data/db/migrate/20250608112101_add_oauth_to_sessions.rb +19 -0
- data/lib/action_mcp/client/oauth_client_provider/memory_storage.rb +47 -0
- data/lib/action_mcp/client/oauth_client_provider.rb +234 -0
- data/lib/action_mcp/client/streamable_http_transport.rb +25 -1
- data/lib/action_mcp/client.rb +15 -2
- data/lib/action_mcp/configuration.rb +65 -6
- data/lib/action_mcp/engine.rb +8 -0
- data/lib/action_mcp/gateway.rb +95 -6
- data/lib/action_mcp/oauth/error.rb +79 -0
- data/lib/action_mcp/oauth/memory_storage.rb +112 -0
- data/lib/action_mcp/oauth/middleware.rb +100 -0
- data/lib/action_mcp/oauth/provider.rb +390 -0
- data/lib/action_mcp/omniauth/mcp_strategy.rb +176 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +6 -1
- data/lib/generators/action_mcp/config/templates/mcp.yml +71 -3
- metadata +81 -1
@@ -0,0 +1,234 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "faraday"
|
4
|
+
require "pkce_challenge"
|
5
|
+
require "securerandom"
|
6
|
+
require "uri"
|
7
|
+
require "json"
|
8
|
+
|
9
|
+
module ActionMCP
|
10
|
+
module Client
|
11
|
+
# OAuth client provider for MCP client authentication
|
12
|
+
# Implements OAuth 2.1 authorization code flow with PKCE
|
13
|
+
class OauthClientProvider
|
14
|
+
class AuthenticationError < StandardError; end
|
15
|
+
class TokenExpiredError < StandardError; end
|
16
|
+
attr_reader :redirect_url, :client_metadata, :authorization_server_url
|
17
|
+
|
18
|
+
def initialize(
|
19
|
+
authorization_server_url:,
|
20
|
+
redirect_url:,
|
21
|
+
client_metadata: {},
|
22
|
+
storage: nil,
|
23
|
+
logger: ActionMCP.logger
|
24
|
+
)
|
25
|
+
@authorization_server_url = URI(authorization_server_url)
|
26
|
+
@redirect_url = URI(redirect_url)
|
27
|
+
@client_metadata = default_client_metadata.merge(client_metadata)
|
28
|
+
@storage = storage || MemoryStorage.new
|
29
|
+
@logger = logger
|
30
|
+
@http_client = build_http_client
|
31
|
+
end
|
32
|
+
|
33
|
+
# Get current access token for authorization headers
|
34
|
+
def access_token
|
35
|
+
tokens = current_tokens
|
36
|
+
return nil unless tokens
|
37
|
+
|
38
|
+
if token_expired?(tokens)
|
39
|
+
refresh_tokens! if tokens[:refresh_token]
|
40
|
+
tokens = current_tokens
|
41
|
+
end
|
42
|
+
|
43
|
+
tokens&.dig(:access_token)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Check if client has valid authentication
|
47
|
+
def authenticated?
|
48
|
+
!access_token.nil?
|
49
|
+
end
|
50
|
+
|
51
|
+
# Start OAuth authorization flow
|
52
|
+
def start_authorization_flow(scope: nil, state: nil)
|
53
|
+
# Generate PKCE challenge
|
54
|
+
pkce = PkceChallenge.challenge
|
55
|
+
code_verifier = pkce.code_verifier
|
56
|
+
code_challenge = pkce.code_challenge
|
57
|
+
@storage.save_code_verifier(code_verifier)
|
58
|
+
|
59
|
+
# Build authorization URL
|
60
|
+
auth_params = {
|
61
|
+
response_type: "code",
|
62
|
+
client_id: client_id,
|
63
|
+
redirect_uri: @redirect_url.to_s,
|
64
|
+
code_challenge: code_challenge,
|
65
|
+
code_challenge_method: "S256"
|
66
|
+
}
|
67
|
+
auth_params[:scope] = scope if scope
|
68
|
+
auth_params[:state] = state if state
|
69
|
+
|
70
|
+
authorization_url = build_url(server_metadata[:authorization_endpoint], auth_params)
|
71
|
+
|
72
|
+
log_debug("Starting OAuth flow: #{authorization_url}")
|
73
|
+
authorization_url
|
74
|
+
end
|
75
|
+
|
76
|
+
# Complete OAuth flow with authorization code
|
77
|
+
def complete_authorization_flow(authorization_code, state: nil)
|
78
|
+
code_verifier = @storage.load_code_verifier
|
79
|
+
raise AuthenticationError, "No code verifier found" unless code_verifier
|
80
|
+
|
81
|
+
# Exchange code for tokens
|
82
|
+
token_params = {
|
83
|
+
grant_type: "authorization_code",
|
84
|
+
code: authorization_code,
|
85
|
+
redirect_uri: @redirect_url.to_s,
|
86
|
+
code_verifier: code_verifier,
|
87
|
+
client_id: client_id
|
88
|
+
}
|
89
|
+
|
90
|
+
response = @http_client.post(server_metadata[:token_endpoint]) do |req|
|
91
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
92
|
+
req.headers["Accept"] = "application/json"
|
93
|
+
req.body = URI.encode_www_form(token_params)
|
94
|
+
end
|
95
|
+
|
96
|
+
handle_token_response(response)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Refresh access token using refresh token
|
100
|
+
def refresh_tokens!
|
101
|
+
tokens = current_tokens
|
102
|
+
refresh_token = tokens&.dig(:refresh_token)
|
103
|
+
raise TokenExpiredError, "No refresh token available" unless refresh_token
|
104
|
+
|
105
|
+
token_params = {
|
106
|
+
grant_type: "refresh_token",
|
107
|
+
refresh_token: refresh_token,
|
108
|
+
client_id: client_id
|
109
|
+
}
|
110
|
+
|
111
|
+
response = @http_client.post(server_metadata[:token_endpoint]) do |req|
|
112
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
113
|
+
req.headers["Accept"] = "application/json"
|
114
|
+
req.body = URI.encode_www_form(token_params)
|
115
|
+
end
|
116
|
+
|
117
|
+
handle_token_response(response)
|
118
|
+
end
|
119
|
+
|
120
|
+
# Clear stored tokens (logout)
|
121
|
+
def clear_tokens!
|
122
|
+
@storage.clear_tokens
|
123
|
+
@storage.clear_code_verifier if @storage.respond_to?(:clear_code_verifier)
|
124
|
+
log_debug("Cleared OAuth tokens and code verifier")
|
125
|
+
end
|
126
|
+
|
127
|
+
# Get client information for registration
|
128
|
+
def client_information
|
129
|
+
@storage.load_client_information
|
130
|
+
end
|
131
|
+
|
132
|
+
# Save client information after registration
|
133
|
+
def save_client_information(client_info)
|
134
|
+
@storage.save_client_information(client_info)
|
135
|
+
end
|
136
|
+
|
137
|
+
# Get authorization headers for HTTP requests
|
138
|
+
def authorization_headers
|
139
|
+
token = access_token
|
140
|
+
return {} unless token
|
141
|
+
|
142
|
+
{ "Authorization" => "Bearer #{token}" }
|
143
|
+
end
|
144
|
+
|
145
|
+
private
|
146
|
+
|
147
|
+
def current_tokens
|
148
|
+
@storage.load_tokens
|
149
|
+
end
|
150
|
+
|
151
|
+
def save_tokens(tokens)
|
152
|
+
@storage.save_tokens(tokens)
|
153
|
+
end
|
154
|
+
|
155
|
+
def token_expired?(tokens)
|
156
|
+
expires_at = tokens[:expires_at]
|
157
|
+
return false unless expires_at
|
158
|
+
|
159
|
+
Time.at(expires_at) <= Time.now + 30 # 30 second buffer
|
160
|
+
end
|
161
|
+
|
162
|
+
def client_id
|
163
|
+
client_info = client_information
|
164
|
+
client_info&.dig(:client_id) || @client_metadata[:client_id]
|
165
|
+
end
|
166
|
+
|
167
|
+
def server_metadata
|
168
|
+
@server_metadata ||= fetch_server_metadata
|
169
|
+
end
|
170
|
+
|
171
|
+
def fetch_server_metadata
|
172
|
+
well_known_url = @authorization_server_url.dup
|
173
|
+
well_known_url.path = "/.well-known/oauth-authorization-server"
|
174
|
+
|
175
|
+
response = @http_client.get(well_known_url)
|
176
|
+
unless response.success?
|
177
|
+
raise AuthenticationError, "Failed to fetch server metadata: #{response.status}"
|
178
|
+
end
|
179
|
+
|
180
|
+
JSON.parse(response.body, symbolize_names: true)
|
181
|
+
end
|
182
|
+
|
183
|
+
def handle_token_response(response)
|
184
|
+
unless response.success?
|
185
|
+
error_body = JSON.parse(response.body) rescue {}
|
186
|
+
error_msg = error_body["error_description"] || error_body["error"] || "Token request failed"
|
187
|
+
raise AuthenticationError, "#{error_msg} (#{response.status})"
|
188
|
+
end
|
189
|
+
|
190
|
+
token_data = JSON.parse(response.body, symbolize_names: true)
|
191
|
+
|
192
|
+
# Calculate token expiration
|
193
|
+
if token_data[:expires_in]
|
194
|
+
token_data[:expires_at] = Time.now.to_i + token_data[:expires_in].to_i
|
195
|
+
end
|
196
|
+
|
197
|
+
save_tokens(token_data)
|
198
|
+
log_debug("OAuth tokens obtained successfully")
|
199
|
+
token_data
|
200
|
+
end
|
201
|
+
|
202
|
+
def build_url(base_url, params)
|
203
|
+
uri = URI(base_url)
|
204
|
+
uri.query = URI.encode_www_form(params)
|
205
|
+
uri.to_s
|
206
|
+
end
|
207
|
+
|
208
|
+
def build_http_client
|
209
|
+
Faraday.new do |f|
|
210
|
+
f.headers["User-Agent"] = "ActionMCP-OAuth/#{ActionMCP.gem_version}"
|
211
|
+
f.options.timeout = 30
|
212
|
+
f.options.open_timeout = 10
|
213
|
+
f.adapter :net_http
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def default_client_metadata
|
218
|
+
{
|
219
|
+
client_name: "ActionMCP Client",
|
220
|
+
client_uri: "https://github.com/anthropics/action_mcp",
|
221
|
+
redirect_uris: [ @redirect_url.to_s ],
|
222
|
+
grant_types: [ "authorization_code", "refresh_token" ],
|
223
|
+
response_types: [ "code" ],
|
224
|
+
token_endpoint_auth_method: "none", # Public client
|
225
|
+
code_challenge_methods_supported: [ "S256" ]
|
226
|
+
}
|
227
|
+
end
|
228
|
+
|
229
|
+
def log_debug(message)
|
230
|
+
@logger.debug("[ActionMCP::OAuthClientProvider] #{message}")
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
@@ -15,9 +15,10 @@ module ActionMCP
|
|
15
15
|
|
16
16
|
attr_reader :session_id, :last_event_id
|
17
17
|
|
18
|
-
def initialize(url, session_store:, session_id: nil, **options)
|
18
|
+
def initialize(url, session_store:, session_id: nil, oauth_provider: nil, **options)
|
19
19
|
super(url, session_store: session_store, **options)
|
20
20
|
@session_id = session_id
|
21
|
+
@oauth_provider = oauth_provider
|
21
22
|
@last_event_id = nil
|
22
23
|
@buffer = +""
|
23
24
|
@current_event = nil
|
@@ -97,6 +98,7 @@ module ActionMCP
|
|
97
98
|
}
|
98
99
|
headers["mcp-session-id"] = @session_id if @session_id
|
99
100
|
headers["Last-Event-ID"] = @last_event_id if @last_event_id
|
101
|
+
headers.merge!(oauth_headers)
|
100
102
|
headers
|
101
103
|
end
|
102
104
|
|
@@ -106,6 +108,7 @@ module ActionMCP
|
|
106
108
|
"Accept" => "application/json, text/event-stream"
|
107
109
|
}
|
108
110
|
headers["mcp-session-id"] = @session_id if @session_id
|
111
|
+
headers.merge!(oauth_headers)
|
109
112
|
headers
|
110
113
|
end
|
111
114
|
|
@@ -190,6 +193,7 @@ module ActionMCP
|
|
190
193
|
# Accepted - message received, no immediate response
|
191
194
|
log_debug("Message accepted (202)")
|
192
195
|
when 401
|
196
|
+
handle_authentication_error(response)
|
193
197
|
raise AuthenticationError, "Authentication required"
|
194
198
|
when 405
|
195
199
|
# Method not allowed - server doesn't support this operation
|
@@ -283,6 +287,26 @@ module ActionMCP
|
|
283
287
|
log_debug("Saved session state")
|
284
288
|
end
|
285
289
|
|
290
|
+
def oauth_headers
|
291
|
+
return {} unless @oauth_provider&.authenticated?
|
292
|
+
|
293
|
+
@oauth_provider.authorization_headers
|
294
|
+
rescue StandardError => e
|
295
|
+
log_error("Failed to get OAuth headers: #{e.message}")
|
296
|
+
{}
|
297
|
+
end
|
298
|
+
|
299
|
+
def handle_authentication_error(response)
|
300
|
+
return unless @oauth_provider
|
301
|
+
|
302
|
+
# Check for OAuth challenge in WWW-Authenticate header
|
303
|
+
www_auth = response.headers["www-authenticate"]
|
304
|
+
if www_auth&.include?("Bearer")
|
305
|
+
log_debug("Received OAuth challenge, clearing tokens")
|
306
|
+
@oauth_provider.clear_tokens!
|
307
|
+
end
|
308
|
+
end
|
309
|
+
|
286
310
|
def user_agent
|
287
311
|
"ActionMCP-StreamableHTTP/#{ActionMCP.gem_version}"
|
288
312
|
end
|
data/lib/action_mcp/client.rb
CHANGED
@@ -3,6 +3,7 @@
|
|
3
3
|
require_relative "client/transport"
|
4
4
|
require_relative "client/session_store"
|
5
5
|
require_relative "client/streamable_http_transport"
|
6
|
+
require_relative "client/oauth_client_provider"
|
6
7
|
|
7
8
|
module ActionMCP
|
8
9
|
# Creates a client appropriate for the given endpoint.
|
@@ -11,6 +12,7 @@ module ActionMCP
|
|
11
12
|
# @param transport [Symbol] The transport type to use (:streamable_http, :sse for legacy)
|
12
13
|
# @param session_store [Symbol] The session store type (:memory, :active_record)
|
13
14
|
# @param session_id [String] Optional session ID for resuming connections
|
15
|
+
# @param oauth_provider [ActionMCP::Client::OauthClientProvider] Optional OAuth provider for authentication
|
14
16
|
# @param logger [Logger] The logger to use. Default is Logger.new($stdout).
|
15
17
|
# @param options [Hash] Additional options to pass to the client constructor.
|
16
18
|
#
|
@@ -33,7 +35,18 @@ module ActionMCP
|
|
33
35
|
# "http://127.0.0.1:3001/action_mcp",
|
34
36
|
# session_store: :memory
|
35
37
|
# )
|
36
|
-
|
38
|
+
#
|
39
|
+
# @example With OAuth authentication
|
40
|
+
# oauth_provider = ActionMCP::Client::OauthClientProvider.new(
|
41
|
+
# authorization_server_url: "https://oauth.example.com",
|
42
|
+
# redirect_url: "http://localhost:3000/callback",
|
43
|
+
# client_metadata: { client_name: "My App" }
|
44
|
+
# )
|
45
|
+
# client = ActionMCP.create_client(
|
46
|
+
# "http://127.0.0.1:3001/action_mcp",
|
47
|
+
# oauth_provider: oauth_provider
|
48
|
+
# )
|
49
|
+
def self.create_client(endpoint, transport: :streamable_http, session_store: nil, session_id: nil, oauth_provider: nil, logger: Logger.new($stdout), **options)
|
37
50
|
unless endpoint =~ %r{\Ahttps?://}
|
38
51
|
raise ArgumentError, "Only HTTP(S) endpoints are supported. STDIO and other transports are not supported."
|
39
52
|
end
|
@@ -42,7 +55,7 @@ module ActionMCP
|
|
42
55
|
store = Client::SessionStoreFactory.create(session_store, **options)
|
43
56
|
|
44
57
|
# Create transport
|
45
|
-
transport_instance = create_transport(transport, endpoint, session_store: store, session_id: session_id, logger: logger, **options)
|
58
|
+
transport_instance = create_transport(transport, endpoint, session_store: store, session_id: session_id, oauth_provider: oauth_provider, logger: logger, **options)
|
46
59
|
|
47
60
|
logger.info("Creating #{transport} client for endpoint: #{endpoint}")
|
48
61
|
# Pass session_id to the client
|
@@ -25,6 +25,9 @@ module ActionMCP
|
|
25
25
|
:logging_level,
|
26
26
|
:active_profile,
|
27
27
|
:profiles,
|
28
|
+
# --- Authentication Options ---
|
29
|
+
:authentication_methods,
|
30
|
+
:oauth_config,
|
28
31
|
# --- Transport Options ---
|
29
32
|
:sse_heartbeat_interval,
|
30
33
|
:post_response_preference, # :json or :sse
|
@@ -36,9 +39,15 @@ module ActionMCP
|
|
36
39
|
:max_stored_sse_events,
|
37
40
|
# --- Gateway Options ---
|
38
41
|
:gateway_class,
|
39
|
-
:current_class,
|
40
42
|
# --- Session Store Options ---
|
41
|
-
:session_store_type
|
43
|
+
:session_store_type,
|
44
|
+
# --- Pub/Sub and Thread Pool Options ---
|
45
|
+
:adapter,
|
46
|
+
:min_threads,
|
47
|
+
:max_threads,
|
48
|
+
:max_queue,
|
49
|
+
:polling_interval,
|
50
|
+
:connects_to
|
42
51
|
|
43
52
|
def initialize
|
44
53
|
@logging_enabled = true
|
@@ -47,6 +56,11 @@ module ActionMCP
|
|
47
56
|
@resources_subscribe = false
|
48
57
|
@active_profile = :primary
|
49
58
|
@profiles = default_profiles
|
59
|
+
|
60
|
+
# Authentication defaults
|
61
|
+
@authentication_methods = Rails.env.production? ? [ "jwt" ] : [ "none" ]
|
62
|
+
@oauth_config = {}
|
63
|
+
|
50
64
|
@sse_heartbeat_interval = 30
|
51
65
|
@post_response_preference = :json
|
52
66
|
@protocol_version = "2025-03-26"
|
@@ -58,7 +72,6 @@ module ActionMCP
|
|
58
72
|
|
59
73
|
# Gateway - default to ApplicationGateway if it exists, otherwise ActionMCP::Gateway
|
60
74
|
@gateway_class = defined?(::ApplicationGateway) ? ::ApplicationGateway : ActionMCP::Gateway
|
61
|
-
@current_class = nil
|
62
75
|
|
63
76
|
# Session Store
|
64
77
|
@session_store_type = Rails.env.production? ? :active_record : :volatile
|
@@ -77,7 +90,7 @@ module ActionMCP
|
|
77
90
|
ActionMCP.thread_profiles.value || @active_profile
|
78
91
|
end
|
79
92
|
|
80
|
-
# Load custom
|
93
|
+
# Load custom configuration from Rails configuration
|
81
94
|
def load_profiles
|
82
95
|
# First load defaults from the gem
|
83
96
|
@profiles = default_profiles
|
@@ -88,8 +101,23 @@ module ActionMCP
|
|
88
101
|
|
89
102
|
raise "Invalid MCP config file" unless app_config.is_a?(Hash)
|
90
103
|
|
91
|
-
#
|
92
|
-
|
104
|
+
# Extract authentication configuration if present
|
105
|
+
if app_config["authentication"]
|
106
|
+
@authentication_methods = Array(app_config["authentication"])
|
107
|
+
end
|
108
|
+
|
109
|
+
# Extract OAuth configuration if present
|
110
|
+
if app_config["oauth"]
|
111
|
+
@oauth_config = app_config["oauth"]
|
112
|
+
end
|
113
|
+
|
114
|
+
# Extract other top-level configuration settings
|
115
|
+
extract_top_level_settings(app_config)
|
116
|
+
|
117
|
+
# Extract profiles configuration
|
118
|
+
if app_config["profiles"]
|
119
|
+
@profiles = app_config["profiles"]
|
120
|
+
end
|
93
121
|
rescue StandardError
|
94
122
|
# If the config file doesn't exist in the Rails app, just use the defaults
|
95
123
|
Rails.logger.debug "No MCP config found in Rails app, using defaults from gem"
|
@@ -197,6 +225,37 @@ module ActionMCP
|
|
197
225
|
}
|
198
226
|
end
|
199
227
|
|
228
|
+
def extract_top_level_settings(app_config)
|
229
|
+
# Extract adapter configuration
|
230
|
+
if app_config["adapter"]
|
231
|
+
# This will be handled by the pub/sub system, we just store it for now
|
232
|
+
@adapter = app_config["adapter"]
|
233
|
+
end
|
234
|
+
|
235
|
+
# Extract thread pool settings
|
236
|
+
if app_config["min_threads"]
|
237
|
+
@min_threads = app_config["min_threads"]
|
238
|
+
end
|
239
|
+
|
240
|
+
if app_config["max_threads"]
|
241
|
+
@max_threads = app_config["max_threads"]
|
242
|
+
end
|
243
|
+
|
244
|
+
if app_config["max_queue"]
|
245
|
+
@max_queue = app_config["max_queue"]
|
246
|
+
end
|
247
|
+
|
248
|
+
# Extract polling interval for solid_cable
|
249
|
+
if app_config["polling_interval"]
|
250
|
+
@polling_interval = app_config["polling_interval"]
|
251
|
+
end
|
252
|
+
|
253
|
+
# Extract connects_to setting
|
254
|
+
if app_config["connects_to"]
|
255
|
+
@connects_to = app_config["connects_to"]
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
200
259
|
def should_include_all?(type)
|
201
260
|
return false unless @profiles[active_profile]
|
202
261
|
|
data/lib/action_mcp/engine.rb
CHANGED
@@ -12,6 +12,7 @@ module ActionMCP
|
|
12
12
|
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
13
13
|
inflect.acronym "SSE"
|
14
14
|
inflect.acronym "MCP"
|
15
|
+
inflect.acronym "OAuth"
|
15
16
|
end
|
16
17
|
|
17
18
|
# Provide a configuration namespace for ActionMCP
|
@@ -28,6 +29,13 @@ module ActionMCP
|
|
28
29
|
ActionMCP.configuration.load_profiles
|
29
30
|
end
|
30
31
|
|
32
|
+
# Add OAuth middleware if OAuth is configured
|
33
|
+
initializer "action_mcp.oauth_middleware", after: "action_mcp.load_profiles" do
|
34
|
+
if ActionMCP.configuration.authentication_methods&.include?("oauth")
|
35
|
+
config.middleware.use ActionMCP::OAuth::Middleware
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
31
39
|
# Configure autoloading for the mcp/tools directory
|
32
40
|
initializer "action_mcp.autoloading", before: :set_autoload_paths do |app|
|
33
41
|
mcp_path = app.root.join("app/mcp")
|
data/lib/action_mcp/gateway.rb
CHANGED
@@ -49,13 +49,22 @@ module ActionMCP
|
|
49
49
|
protected
|
50
50
|
|
51
51
|
def authenticate!
|
52
|
-
|
53
|
-
|
52
|
+
auth_methods = ActionMCP.configuration.authentication_methods || [ "jwt" ]
|
53
|
+
|
54
|
+
auth_methods.each do |method|
|
55
|
+
case method
|
56
|
+
when "none"
|
57
|
+
return default_user_identity
|
58
|
+
when "jwt"
|
59
|
+
result = jwt_authenticate
|
60
|
+
return result if result
|
61
|
+
when "oauth"
|
62
|
+
result = oauth_authenticate
|
63
|
+
return result if result
|
64
|
+
end
|
65
|
+
end
|
54
66
|
|
55
|
-
|
56
|
-
resolve_user(payload)
|
57
|
-
rescue ActionMCP::JwtDecoder::DecodeError => e
|
58
|
-
raise UnauthorizedError, e.message
|
67
|
+
raise UnauthorizedError, "No valid authentication found"
|
59
68
|
end
|
60
69
|
|
61
70
|
def extract_bearer_token
|
@@ -81,5 +90,85 @@ module ActionMCP
|
|
81
90
|
def reject_unauthorized_connection
|
82
91
|
raise UnauthorizedError, "Unauthorized"
|
83
92
|
end
|
93
|
+
|
94
|
+
# Default user identity for "none" authentication
|
95
|
+
def default_user_identity
|
96
|
+
# Return a hash with all identified_by attributes set to a default user
|
97
|
+
self.class.identifiers.each_with_object({}) do |identifier, hash|
|
98
|
+
if identifier == :user
|
99
|
+
# Create or find a default user for development
|
100
|
+
hash[identifier] = find_or_create_default_user
|
101
|
+
end
|
102
|
+
# Add support for other identifiers as needed
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# JWT authentication (existing implementation)
|
107
|
+
def jwt_authenticate
|
108
|
+
token = extract_bearer_token
|
109
|
+
unless token
|
110
|
+
raise UnauthorizedError, "Missing token" if ActionMCP.configuration.authentication_methods == [ "jwt" ]
|
111
|
+
return nil
|
112
|
+
end
|
113
|
+
|
114
|
+
payload = ActionMCP::JwtDecoder.decode(token)
|
115
|
+
result = resolve_user(payload)
|
116
|
+
unless result
|
117
|
+
raise UnauthorizedError, "Unauthorized" if ActionMCP.configuration.authentication_methods == [ "jwt" ]
|
118
|
+
return nil
|
119
|
+
end
|
120
|
+
result
|
121
|
+
rescue ActionMCP::JwtDecoder::DecodeError => e
|
122
|
+
if ActionMCP.configuration.authentication_methods == [ "jwt" ]
|
123
|
+
raise UnauthorizedError, "Invalid token"
|
124
|
+
else
|
125
|
+
nil # Let it try other authentication methods
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# OAuth authentication via middleware
|
130
|
+
def oauth_authenticate
|
131
|
+
return nil unless oauth_enabled?
|
132
|
+
|
133
|
+
# Check if OAuth middleware has already validated the token
|
134
|
+
token_info = request.env["action_mcp.oauth_token_info"]
|
135
|
+
return nil unless token_info && token_info["active"]
|
136
|
+
|
137
|
+
resolve_user_from_oauth(token_info)
|
138
|
+
rescue ActionMCP::OAuth::Error
|
139
|
+
nil # Let it try other authentication methods
|
140
|
+
end
|
141
|
+
|
142
|
+
def oauth_enabled?
|
143
|
+
ActionMCP.configuration.authentication_methods&.include?("oauth") &&
|
144
|
+
ActionMCP.configuration.oauth_config.present?
|
145
|
+
end
|
146
|
+
|
147
|
+
def resolve_user_from_oauth(token_info)
|
148
|
+
return nil unless token_info.is_a?(Hash)
|
149
|
+
|
150
|
+
user_id = token_info["sub"] || token_info["user_id"]
|
151
|
+
return nil unless user_id
|
152
|
+
|
153
|
+
user = User.find_by(id: user_id) || User.find_by(oauth_subject: user_id)
|
154
|
+
return nil unless user
|
155
|
+
|
156
|
+
# Return a hash with all identified_by attributes
|
157
|
+
self.class.identifiers.each_with_object({}) do |identifier, hash|
|
158
|
+
hash[identifier] = user if identifier == :user
|
159
|
+
# Add support for other identifiers as needed
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def find_or_create_default_user
|
164
|
+
# Only for development/testing with "none" authentication
|
165
|
+
return nil unless Rails.env.development? || Rails.env.test?
|
166
|
+
|
167
|
+
if defined?(User)
|
168
|
+
User.find_or_create_by(email: "dev@localhost") do |user|
|
169
|
+
user.name = "Development User" if user.respond_to?(:name=)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
84
173
|
end
|
85
174
|
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module OAuth
|
5
|
+
# Base OAuth error class
|
6
|
+
class Error < StandardError
|
7
|
+
attr_reader :oauth_error_code
|
8
|
+
|
9
|
+
def initialize(message, oauth_error_code = "invalid_request")
|
10
|
+
super(message)
|
11
|
+
@oauth_error_code = oauth_error_code
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# OAuth 2.1 standard error types
|
16
|
+
class InvalidRequestError < Error
|
17
|
+
def initialize(message = "Invalid request")
|
18
|
+
super(message, "invalid_request")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class InvalidClientError < Error
|
23
|
+
def initialize(message = "Invalid client")
|
24
|
+
super(message, "invalid_client")
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
class InvalidGrantError < Error
|
29
|
+
def initialize(message = "Invalid grant")
|
30
|
+
super(message, "invalid_grant")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class UnauthorizedClientError < Error
|
35
|
+
def initialize(message = "Unauthorized client")
|
36
|
+
super(message, "unauthorized_client")
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
class UnsupportedGrantTypeError < Error
|
41
|
+
def initialize(message = "Unsupported grant type")
|
42
|
+
super(message, "unsupported_grant_type")
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
class InvalidScopeError < Error
|
47
|
+
def initialize(message = "Invalid scope")
|
48
|
+
super(message, "invalid_scope")
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
class InvalidTokenError < Error
|
53
|
+
def initialize(message = "Invalid token")
|
54
|
+
super(message, "invalid_token")
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
class InsufficientScopeError < Error
|
59
|
+
attr_reader :required_scope
|
60
|
+
|
61
|
+
def initialize(message = "Insufficient scope", required_scope = nil)
|
62
|
+
super(message, "insufficient_scope")
|
63
|
+
@required_scope = required_scope
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
class ServerError < Error
|
68
|
+
def initialize(message = "Server error")
|
69
|
+
super(message, "server_error")
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
class TemporarilyUnavailableError < Error
|
74
|
+
def initialize(message = "Temporarily unavailable")
|
75
|
+
super(message, "temporarily_unavailable")
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|