actionmcp 0.72.0 → 0.80.1
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 +1 -1
- data/app/controllers/action_mcp/application_controller.rb +20 -12
- data/app/models/action_mcp/session/message.rb +31 -20
- data/app/models/action_mcp/session/resource.rb +35 -20
- data/app/models/action_mcp/session/sse_event.rb +23 -17
- data/app/models/action_mcp/session/subscription.rb +22 -15
- data/app/models/action_mcp/session.rb +42 -119
- data/app/models/concerns/{mcp_console_helpers.rb → action_mcp/mcp_console_helpers.rb} +4 -3
- data/app/models/concerns/{mcp_message_inspect.rb → action_mcp/mcp_message_inspect.rb} +4 -3
- data/config/routes.rb +0 -13
- data/db/migrate/20250727000001_remove_oauth_support.rb +59 -0
- data/lib/action_mcp/client/streamable_http_transport.rb +1 -46
- data/lib/action_mcp/client.rb +2 -25
- data/lib/action_mcp/configuration.rb +51 -24
- data/lib/action_mcp/engine.rb +0 -7
- data/lib/action_mcp/filtered_logger.rb +2 -6
- data/lib/action_mcp/gateway_identifier.rb +187 -3
- data/lib/action_mcp/gateway_identifiers/api_key_identifier.rb +56 -0
- data/lib/action_mcp/gateway_identifiers/devise_identifier.rb +34 -0
- data/lib/action_mcp/gateway_identifiers/request_env_identifier.rb +58 -0
- data/lib/action_mcp/gateway_identifiers/warden_identifier.rb +38 -0
- data/lib/action_mcp/gateway_identifiers.rb +26 -0
- data/lib/action_mcp/resource_template.rb +1 -0
- data/lib/action_mcp/server/base_session.rb +2 -0
- data/lib/action_mcp/server/resources.rb +8 -7
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +1 -6
- data/lib/generators/action_mcp/identifier/identifier_generator.rb +189 -0
- data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +35 -0
- data/lib/generators/action_mcp/install/install_generator.rb +1 -1
- data/lib/generators/action_mcp/install/templates/application_gateway.rb +80 -31
- data/lib/generators/action_mcp/install/templates/mcp.yml +4 -21
- metadata +15 -99
- data/app/controllers/action_mcp/oauth/endpoints_controller.rb +0 -265
- data/app/controllers/action_mcp/oauth/metadata_controller.rb +0 -125
- data/app/controllers/action_mcp/oauth/registration_controller.rb +0 -201
- data/app/models/action_mcp/oauth_client.rb +0 -159
- data/app/models/action_mcp/oauth_token.rb +0 -142
- data/db/migrate/20250608112101_add_oauth_to_sessions.rb +0 -28
- data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +0 -44
- data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +0 -39
- data/lib/action_mcp/client/jwt_client_provider.rb +0 -135
- data/lib/action_mcp/client/oauth_client_provider/memory_storage.rb +0 -47
- data/lib/action_mcp/client/oauth_client_provider.rb +0 -234
- data/lib/action_mcp/jwt_decoder.rb +0 -28
- data/lib/action_mcp/jwt_identifier.rb +0 -28
- data/lib/action_mcp/none_identifier.rb +0 -19
- data/lib/action_mcp/o_auth_identifier.rb +0 -34
- data/lib/action_mcp/oauth/active_record_storage.rb +0 -183
- data/lib/action_mcp/oauth/error.rb +0 -79
- data/lib/action_mcp/oauth/memory_storage.rb +0 -132
- data/lib/action_mcp/oauth/middleware.rb +0 -128
- data/lib/action_mcp/oauth/provider.rb +0 -406
- data/lib/action_mcp/oauth.rb +0 -12
- data/lib/action_mcp/omniauth/mcp_strategy.rb +0 -162
@@ -1,406 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "securerandom"
|
4
|
-
require "digest"
|
5
|
-
require "base64"
|
6
|
-
|
7
|
-
module ActionMCP
|
8
|
-
module OAuth
|
9
|
-
# OAuth 2.1 Provider implementation
|
10
|
-
# Handles authorization codes, access tokens, refresh tokens, and token validation
|
11
|
-
class Provider
|
12
|
-
class << self
|
13
|
-
# Generate authorization code for OAuth flow
|
14
|
-
# @param client_id [String] OAuth client identifier
|
15
|
-
# @param redirect_uri [String] Client redirect URI
|
16
|
-
# @param scope [String] Requested scope
|
17
|
-
# @param code_challenge [String] PKCE code challenge
|
18
|
-
# @param code_challenge_method [String] PKCE challenge method (S256, plain)
|
19
|
-
# @param user_id [String] User identifier
|
20
|
-
# @return [String] Authorization code
|
21
|
-
def generate_authorization_code(client_id:, redirect_uri:, scope:, user_id:, code_challenge: nil,
|
22
|
-
code_challenge_method: nil)
|
23
|
-
# Validate scope
|
24
|
-
validate_scope(scope) if scope
|
25
|
-
|
26
|
-
code = SecureRandom.urlsafe_base64(32)
|
27
|
-
|
28
|
-
# Store authorization code with metadata
|
29
|
-
store_authorization_code(code, {
|
30
|
-
client_id: client_id,
|
31
|
-
redirect_uri: redirect_uri,
|
32
|
-
scope: scope,
|
33
|
-
code_challenge: code_challenge,
|
34
|
-
code_challenge_method: code_challenge_method,
|
35
|
-
user_id: user_id,
|
36
|
-
created_at: Time.current,
|
37
|
-
expires_at: 10.minutes.from_now
|
38
|
-
})
|
39
|
-
|
40
|
-
code
|
41
|
-
end
|
42
|
-
|
43
|
-
# Exchange authorization code for access token
|
44
|
-
# @param code [String] Authorization code
|
45
|
-
# @param client_id [String] OAuth client identifier
|
46
|
-
# @param client_secret [String] OAuth client secret (optional for public clients)
|
47
|
-
# @param redirect_uri [String] Client redirect URI
|
48
|
-
# @param code_verifier [String] PKCE code verifier
|
49
|
-
# @return [Hash] Token response with access_token, token_type, expires_in, scope
|
50
|
-
def exchange_code_for_token(code:, client_id:, redirect_uri:, client_secret: nil, code_verifier: nil)
|
51
|
-
# Retrieve and validate authorization code
|
52
|
-
code_data = retrieve_authorization_code(code)
|
53
|
-
raise InvalidGrantError, "Invalid authorization code" unless code_data
|
54
|
-
raise InvalidGrantError, "Authorization code expired" if code_data[:expires_at] < Time.current
|
55
|
-
|
56
|
-
# Validate client
|
57
|
-
validate_client(client_id, client_secret)
|
58
|
-
|
59
|
-
# Validate redirect URI matches
|
60
|
-
raise InvalidGrantError, "Redirect URI mismatch" unless code_data[:redirect_uri] == redirect_uri
|
61
|
-
|
62
|
-
# Validate client ID matches
|
63
|
-
raise InvalidGrantError, "Client ID mismatch" unless code_data[:client_id] == client_id
|
64
|
-
|
65
|
-
# Validate PKCE if challenge was provided during authorization
|
66
|
-
if code_data[:code_challenge]
|
67
|
-
validate_pkce(code_data[:code_challenge], code_data[:code_challenge_method], code_verifier)
|
68
|
-
end
|
69
|
-
|
70
|
-
# Generate access token
|
71
|
-
access_token = generate_access_token(
|
72
|
-
client_id: client_id,
|
73
|
-
scope: code_data[:scope],
|
74
|
-
user_id: code_data[:user_id]
|
75
|
-
)
|
76
|
-
|
77
|
-
# Generate refresh token if enabled
|
78
|
-
refresh_token = nil
|
79
|
-
if oauth_config[:enable_refresh_tokens]
|
80
|
-
refresh_token = generate_refresh_token(
|
81
|
-
client_id: client_id,
|
82
|
-
scope: code_data[:scope],
|
83
|
-
user_id: code_data[:user_id],
|
84
|
-
access_token: access_token
|
85
|
-
)
|
86
|
-
end
|
87
|
-
|
88
|
-
# Remove used authorization code
|
89
|
-
remove_authorization_code(code)
|
90
|
-
|
91
|
-
# Return token response
|
92
|
-
response = {
|
93
|
-
access_token: access_token,
|
94
|
-
token_type: "Bearer",
|
95
|
-
expires_in: token_expires_in,
|
96
|
-
scope: code_data[:scope]
|
97
|
-
}
|
98
|
-
response[:refresh_token] = refresh_token if refresh_token
|
99
|
-
response
|
100
|
-
end
|
101
|
-
|
102
|
-
# Refresh access token using refresh token
|
103
|
-
# @param refresh_token [String] Refresh token
|
104
|
-
# @param client_id [String] OAuth client identifier
|
105
|
-
# @param client_secret [String] OAuth client secret
|
106
|
-
# @param scope [String] Requested scope (optional, must be subset of original)
|
107
|
-
# @return [Hash] New token response
|
108
|
-
def refresh_access_token(refresh_token:, client_id:, client_secret: nil, scope: nil)
|
109
|
-
# Retrieve refresh token data
|
110
|
-
token_data = retrieve_refresh_token(refresh_token)
|
111
|
-
raise InvalidGrantError, "Invalid refresh token" unless token_data
|
112
|
-
raise InvalidGrantError, "Refresh token expired" if token_data[:expires_at] < Time.current
|
113
|
-
|
114
|
-
# Validate client
|
115
|
-
validate_client(client_id, client_secret)
|
116
|
-
|
117
|
-
# Validate client ID matches
|
118
|
-
raise InvalidGrantError, "Client ID mismatch" unless token_data[:client_id] == client_id
|
119
|
-
|
120
|
-
# Validate scope if provided
|
121
|
-
if scope
|
122
|
-
requested_scopes = scope.split(" ")
|
123
|
-
original_scopes = token_data[:scope].split(" ")
|
124
|
-
unless (requested_scopes - original_scopes).empty?
|
125
|
-
raise InvalidScopeError, "Requested scope exceeds original scope"
|
126
|
-
end
|
127
|
-
else
|
128
|
-
scope = token_data[:scope]
|
129
|
-
end
|
130
|
-
|
131
|
-
# Revoke old access token
|
132
|
-
revoke_access_token(token_data[:access_token]) if token_data[:access_token]
|
133
|
-
|
134
|
-
# Generate new access token
|
135
|
-
access_token = generate_access_token(
|
136
|
-
client_id: client_id,
|
137
|
-
scope: scope,
|
138
|
-
user_id: token_data[:user_id]
|
139
|
-
)
|
140
|
-
|
141
|
-
# Update refresh token with new access token
|
142
|
-
update_refresh_token(refresh_token, access_token)
|
143
|
-
|
144
|
-
{
|
145
|
-
access_token: access_token,
|
146
|
-
token_type: "Bearer",
|
147
|
-
expires_in: token_expires_in,
|
148
|
-
scope: scope
|
149
|
-
}
|
150
|
-
end
|
151
|
-
|
152
|
-
# Validate access token and return token info
|
153
|
-
# @param access_token [String] Access token to validate
|
154
|
-
# @return [Hash] Token info with active, client_id, scope, user_id, exp
|
155
|
-
def introspect_token(access_token)
|
156
|
-
token_data = retrieve_access_token(access_token)
|
157
|
-
|
158
|
-
return { active: false } unless token_data
|
159
|
-
|
160
|
-
if token_data[:expires_at] < Time.current
|
161
|
-
remove_access_token(access_token)
|
162
|
-
return { active: false }
|
163
|
-
end
|
164
|
-
|
165
|
-
{
|
166
|
-
active: true,
|
167
|
-
client_id: token_data[:client_id],
|
168
|
-
scope: token_data[:scope],
|
169
|
-
user_id: token_data[:user_id],
|
170
|
-
exp: token_data[:expires_at].to_i,
|
171
|
-
iat: token_data[:created_at].to_i,
|
172
|
-
token_type: "Bearer"
|
173
|
-
}
|
174
|
-
end
|
175
|
-
|
176
|
-
# Revoke access or refresh token
|
177
|
-
# @param token [String] Token to revoke
|
178
|
-
# @param token_type_hint [String] Type hint: "access_token" or "refresh_token"
|
179
|
-
# @return [Boolean] True if token was revoked
|
180
|
-
def revoke_token(token, token_type_hint: nil)
|
181
|
-
revoked = false
|
182
|
-
|
183
|
-
# Try access token first if hint suggests it or no hint provided
|
184
|
-
if (token_type_hint == "access_token" || token_type_hint.nil?) && retrieve_access_token(token)
|
185
|
-
revoke_access_token(token)
|
186
|
-
revoked = true
|
187
|
-
end
|
188
|
-
|
189
|
-
# Try refresh token if not revoked yet
|
190
|
-
if !revoked && (token_type_hint == "refresh_token" || token_type_hint.nil?) && retrieve_refresh_token(token)
|
191
|
-
revoke_refresh_token(token)
|
192
|
-
revoked = true
|
193
|
-
end
|
194
|
-
|
195
|
-
revoked
|
196
|
-
end
|
197
|
-
|
198
|
-
# Register a new OAuth client (Dynamic Client Registration)
|
199
|
-
# @param client_info [Hash] Client registration information
|
200
|
-
# @return [Hash] Registered client information
|
201
|
-
def register_client(client_info)
|
202
|
-
# Store client registration
|
203
|
-
storage.store_client_registration(client_info[:client_id], client_info)
|
204
|
-
client_info
|
205
|
-
end
|
206
|
-
|
207
|
-
# Retrieve registered client information
|
208
|
-
# @param client_id [String] OAuth client identifier
|
209
|
-
# @return [Hash, nil] Client information or nil if not found
|
210
|
-
def get_client(client_id)
|
211
|
-
storage.retrieve_client_registration(client_id)
|
212
|
-
end
|
213
|
-
|
214
|
-
# Client Credentials Grant (for server-to-server)
|
215
|
-
# @param client_id [String] OAuth client identifier
|
216
|
-
# @param client_secret [String] OAuth client secret
|
217
|
-
# @param scope [String] Requested scope
|
218
|
-
# @return [Hash] Token response
|
219
|
-
def client_credentials_grant(client_id:, client_secret:, scope: nil)
|
220
|
-
unless oauth_config[:enable_client_credentials]
|
221
|
-
raise UnsupportedGrantTypeError, "Client credentials grant not supported"
|
222
|
-
end
|
223
|
-
|
224
|
-
# Validate client credentials
|
225
|
-
validate_client(client_id, client_secret, require_secret: true)
|
226
|
-
|
227
|
-
# Validate scope
|
228
|
-
if scope
|
229
|
-
validate_scope(scope)
|
230
|
-
else
|
231
|
-
scope = default_scope
|
232
|
-
end
|
233
|
-
|
234
|
-
# Generate access token (no user context for client credentials)
|
235
|
-
access_token = generate_access_token(
|
236
|
-
client_id: client_id,
|
237
|
-
scope: scope,
|
238
|
-
user_id: nil
|
239
|
-
)
|
240
|
-
|
241
|
-
{
|
242
|
-
access_token: access_token,
|
243
|
-
token_type: "Bearer",
|
244
|
-
expires_in: token_expires_in,
|
245
|
-
scope: scope
|
246
|
-
}
|
247
|
-
end
|
248
|
-
|
249
|
-
private
|
250
|
-
|
251
|
-
def oauth_config
|
252
|
-
@oauth_config ||= HashWithIndifferentAccess.new(ActionMCP.configuration.oauth_config || {})
|
253
|
-
end
|
254
|
-
|
255
|
-
def validate_client(client_id, client_secret, require_secret: false)
|
256
|
-
# First check if client is registered via dynamic registration
|
257
|
-
client_info = get_client(client_id)
|
258
|
-
if client_info
|
259
|
-
# Validate client secret for confidential clients
|
260
|
-
if client_info[:client_secret]
|
261
|
-
raise InvalidClientError, "Invalid client credentials" unless client_secret == client_info[:client_secret]
|
262
|
-
elsif require_secret
|
263
|
-
raise InvalidClientError, "Client authentication required"
|
264
|
-
end
|
265
|
-
return true
|
266
|
-
end
|
267
|
-
|
268
|
-
# Fall back to custom provider validation
|
269
|
-
provider_class = oauth_config[:provider]
|
270
|
-
if provider_class.respond_to?(:validate_client)
|
271
|
-
provider_class.validate_client(client_id, client_secret)
|
272
|
-
elsif require_secret && client_secret.nil?
|
273
|
-
raise InvalidClientError, "Client authentication required"
|
274
|
-
else
|
275
|
-
# In development, allow unregistered clients if configured
|
276
|
-
return true if Rails.env.development? && oauth_config[:allow_unregistered_clients] != false
|
277
|
-
|
278
|
-
raise InvalidClientError, "Unknown client"
|
279
|
-
end
|
280
|
-
end
|
281
|
-
|
282
|
-
def validate_pkce(code_challenge, method, code_verifier)
|
283
|
-
raise InvalidGrantError, "Code verifier required" unless code_verifier
|
284
|
-
|
285
|
-
case method
|
286
|
-
when "S256"
|
287
|
-
expected_challenge = Base64.urlsafe_encode64(
|
288
|
-
Digest::SHA256.digest(code_verifier), padding: false
|
289
|
-
)
|
290
|
-
raise InvalidGrantError, "Invalid code verifier" unless code_challenge == expected_challenge
|
291
|
-
when "plain"
|
292
|
-
raise InvalidGrantError, "Plain PKCE not allowed" unless oauth_config[:allow_plain_pkce]
|
293
|
-
raise InvalidGrantError, "Invalid code verifier" unless code_challenge == code_verifier
|
294
|
-
else
|
295
|
-
raise InvalidGrantError, "Unsupported code challenge method"
|
296
|
-
end
|
297
|
-
end
|
298
|
-
|
299
|
-
def validate_scope(scope)
|
300
|
-
supported_scopes = oauth_config.fetch(:scopes_supported, [ "mcp:tools", "mcp:resources", "mcp:prompts" ])
|
301
|
-
requested_scopes = scope.split(" ")
|
302
|
-
unsupported = requested_scopes - supported_scopes
|
303
|
-
return unless unsupported.any?
|
304
|
-
|
305
|
-
raise InvalidScopeError, "Unsupported scopes: #{unsupported.join(', ')}"
|
306
|
-
end
|
307
|
-
|
308
|
-
def default_scope
|
309
|
-
oauth_config.fetch(:default_scope, "mcp:tools mcp:resources mcp:prompts")
|
310
|
-
end
|
311
|
-
|
312
|
-
def generate_access_token(client_id:, scope:, user_id:)
|
313
|
-
token = SecureRandom.urlsafe_base64(32)
|
314
|
-
|
315
|
-
store_access_token(token, {
|
316
|
-
client_id: client_id,
|
317
|
-
scope: scope,
|
318
|
-
user_id: user_id,
|
319
|
-
created_at: Time.current,
|
320
|
-
expires_at: token_expires_in.seconds.from_now
|
321
|
-
})
|
322
|
-
|
323
|
-
token
|
324
|
-
end
|
325
|
-
|
326
|
-
def generate_refresh_token(client_id:, scope:, user_id:, access_token:)
|
327
|
-
token = SecureRandom.urlsafe_base64(32)
|
328
|
-
|
329
|
-
store_refresh_token(token, {
|
330
|
-
client_id: client_id,
|
331
|
-
scope: scope,
|
332
|
-
user_id: user_id,
|
333
|
-
access_token: access_token,
|
334
|
-
created_at: Time.current,
|
335
|
-
expires_at: refresh_token_expires_in.seconds.from_now
|
336
|
-
})
|
337
|
-
|
338
|
-
token
|
339
|
-
end
|
340
|
-
|
341
|
-
def token_expires_in
|
342
|
-
oauth_config.fetch(:access_token_expires_in, 3600) # 1 hour
|
343
|
-
end
|
344
|
-
|
345
|
-
def refresh_token_expires_in
|
346
|
-
oauth_config.fetch(:refresh_token_expires_in, 7.days.to_i) # 1 week
|
347
|
-
end
|
348
|
-
|
349
|
-
# Storage methods - these delegate to a configurable storage backend
|
350
|
-
def storage
|
351
|
-
@storage ||= begin
|
352
|
-
# Default to ActiveRecord storage for production, memory for test
|
353
|
-
default_storage = Rails.env.test? ? "ActionMCP::OAuth::MemoryStorage" : "ActionMCP::OAuth::ActiveRecordStorage"
|
354
|
-
storage_class = oauth_config.fetch(:storage, default_storage)
|
355
|
-
storage_class = storage_class.constantize if storage_class.is_a?(String)
|
356
|
-
storage_class.new
|
357
|
-
end
|
358
|
-
end
|
359
|
-
|
360
|
-
def store_authorization_code(code, data)
|
361
|
-
storage.store_authorization_code(code, data)
|
362
|
-
end
|
363
|
-
|
364
|
-
def retrieve_authorization_code(code)
|
365
|
-
storage.retrieve_authorization_code(code)
|
366
|
-
end
|
367
|
-
|
368
|
-
def remove_authorization_code(code)
|
369
|
-
storage.remove_authorization_code(code)
|
370
|
-
end
|
371
|
-
|
372
|
-
def store_access_token(token, data)
|
373
|
-
storage.store_access_token(token, data)
|
374
|
-
end
|
375
|
-
|
376
|
-
def retrieve_access_token(token)
|
377
|
-
storage.retrieve_access_token(token)
|
378
|
-
end
|
379
|
-
|
380
|
-
def remove_access_token(token)
|
381
|
-
storage.remove_access_token(token)
|
382
|
-
end
|
383
|
-
|
384
|
-
def revoke_access_token(token)
|
385
|
-
storage.remove_access_token(token)
|
386
|
-
end
|
387
|
-
|
388
|
-
def store_refresh_token(token, data)
|
389
|
-
storage.store_refresh_token(token, data)
|
390
|
-
end
|
391
|
-
|
392
|
-
def retrieve_refresh_token(token)
|
393
|
-
storage.retrieve_refresh_token(token)
|
394
|
-
end
|
395
|
-
|
396
|
-
def update_refresh_token(token, new_access_token)
|
397
|
-
storage.update_refresh_token(token, new_access_token)
|
398
|
-
end
|
399
|
-
|
400
|
-
def revoke_refresh_token(token)
|
401
|
-
storage.remove_refresh_token(token)
|
402
|
-
end
|
403
|
-
end
|
404
|
-
end
|
405
|
-
end
|
406
|
-
end
|
data/lib/action_mcp/oauth.rb
DELETED
@@ -1,12 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module ActionMCP
|
4
|
-
module OAuth
|
5
|
-
# Load OAuth components
|
6
|
-
autoload :Error, "action_mcp/oauth/error"
|
7
|
-
autoload :Provider, "action_mcp/oauth/provider"
|
8
|
-
autoload :Middleware, "action_mcp/oauth/middleware"
|
9
|
-
autoload :MemoryStorage, "action_mcp/oauth/memory_storage"
|
10
|
-
autoload :ActiveRecordStorage, "action_mcp/oauth/active_record_storage"
|
11
|
-
end
|
12
|
-
end
|
@@ -1,162 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "omniauth-oauth2"
|
4
|
-
|
5
|
-
module ActionMCP
|
6
|
-
module Omniauth
|
7
|
-
# MCP-specific Omniauth strategy for OAuth 2.1 authentication
|
8
|
-
# This strategy integrates with ActionMCP's configuration system and provider interface
|
9
|
-
class MCPStrategy < ::OmniAuth::Strategies::OAuth2
|
10
|
-
# Strategy name used in configuration
|
11
|
-
option :name, "mcp"
|
12
|
-
|
13
|
-
# Default OAuth options with MCP-specific settings
|
14
|
-
option :client_options, {
|
15
|
-
authorize_url: "/oauth/authorize",
|
16
|
-
token_url: "/oauth/token",
|
17
|
-
auth_scheme: :request_body
|
18
|
-
}
|
19
|
-
|
20
|
-
# OAuth 2.1 compliance - PKCE is required
|
21
|
-
option :pkce, true
|
22
|
-
|
23
|
-
# Default scopes for MCP access
|
24
|
-
option :scope, "mcp:tools mcp:resources mcp:prompts"
|
25
|
-
|
26
|
-
# Use authorization code grant flow
|
27
|
-
option :response_type, "code"
|
28
|
-
|
29
|
-
# OAuth server metadata discovery
|
30
|
-
option :discovery, true
|
31
|
-
|
32
|
-
def initialize(app, *args, &block)
|
33
|
-
super
|
34
|
-
|
35
|
-
# Load configuration from ActionMCP if available
|
36
|
-
configure_from_mcp_config if defined?(ActionMCP)
|
37
|
-
end
|
38
|
-
|
39
|
-
# User info from OAuth token response or userinfo endpoint
|
40
|
-
def raw_info
|
41
|
-
@raw_info ||= if options.userinfo_url
|
42
|
-
access_token.get(options.userinfo_url).parsed
|
43
|
-
else
|
44
|
-
# Extract user info from token response or use minimal info
|
45
|
-
access_token.token
|
46
|
-
{
|
47
|
-
"sub" => access_token.params["user_id"] || access_token.token,
|
48
|
-
"scope" => access_token.params["scope"] || options.scope
|
49
|
-
}
|
50
|
-
end
|
51
|
-
rescue ::OAuth2::Error => e
|
52
|
-
log(:error, "Failed to fetch user info: #{e.message}")
|
53
|
-
{}
|
54
|
-
end
|
55
|
-
|
56
|
-
# User ID for Omniauth
|
57
|
-
uid { raw_info["sub"] || raw_info["user_id"] }
|
58
|
-
|
59
|
-
# User info hash
|
60
|
-
info do
|
61
|
-
{
|
62
|
-
name: raw_info["name"],
|
63
|
-
email: raw_info["email"],
|
64
|
-
username: raw_info["username"] || raw_info["preferred_username"]
|
65
|
-
}
|
66
|
-
end
|
67
|
-
|
68
|
-
# Extra credentials and token info
|
69
|
-
extra do
|
70
|
-
{
|
71
|
-
"raw_info" => raw_info,
|
72
|
-
"scope" => access_token.params["scope"],
|
73
|
-
"token_type" => access_token.params["token_type"] || "Bearer"
|
74
|
-
}
|
75
|
-
end
|
76
|
-
|
77
|
-
# OAuth server metadata discovery
|
78
|
-
def discovery_info
|
79
|
-
@discovery_info ||= begin
|
80
|
-
if options.discovery && options.client_options.site
|
81
|
-
discovery_url = "#{options.client_options.site}/.well-known/oauth-authorization-server"
|
82
|
-
response = client.request(:get, discovery_url)
|
83
|
-
JSON.parse(response.body)
|
84
|
-
end
|
85
|
-
rescue StandardError => e
|
86
|
-
log(:warn, "OAuth discovery failed: #{e.message}")
|
87
|
-
{}
|
88
|
-
end
|
89
|
-
end
|
90
|
-
|
91
|
-
# Override client to use discovered endpoints if available
|
92
|
-
def client
|
93
|
-
@client ||= begin
|
94
|
-
if discovery_info.any? && discovery_info["authorization_endpoint"] && discovery_info["token_endpoint"]
|
95
|
-
options.client_options.merge!(
|
96
|
-
authorize_url: discovery_info["authorization_endpoint"],
|
97
|
-
token_url: discovery_info["token_endpoint"]
|
98
|
-
)
|
99
|
-
end
|
100
|
-
super
|
101
|
-
end
|
102
|
-
end
|
103
|
-
|
104
|
-
# Token validation for API requests (not callback flow)
|
105
|
-
def self.validate_token(token, options = {})
|
106
|
-
strategy = new(nil, options)
|
107
|
-
strategy.validate_token(token)
|
108
|
-
end
|
109
|
-
|
110
|
-
def validate_token(token)
|
111
|
-
# Validate access token with OAuth server
|
112
|
-
return nil unless token
|
113
|
-
|
114
|
-
begin
|
115
|
-
response = client.request(:post, options.introspection_url || "/oauth/introspect", {
|
116
|
-
body: { token: token },
|
117
|
-
headers: { "Content-Type" => "application/x-www-form-urlencoded" }
|
118
|
-
})
|
119
|
-
|
120
|
-
token_info = JSON.parse(response.body)
|
121
|
-
return nil unless token_info["active"]
|
122
|
-
|
123
|
-
token_info
|
124
|
-
rescue StandardError => e
|
125
|
-
log(:error, "Token validation failed: #{e.message}")
|
126
|
-
nil
|
127
|
-
end
|
128
|
-
end
|
129
|
-
|
130
|
-
private
|
131
|
-
|
132
|
-
# Configure strategy from ActionMCP configuration
|
133
|
-
def configure_from_mcp_config
|
134
|
-
oauth_config = ActionMCP.configuration.oauth_config
|
135
|
-
return unless oauth_config.is_a?(Hash)
|
136
|
-
|
137
|
-
# Set client options from MCP config
|
138
|
-
options.client_options[:site] = oauth_config["issuer_url"] if oauth_config["issuer_url"]
|
139
|
-
|
140
|
-
options.client_id = oauth_config["client_id"] if oauth_config["client_id"]
|
141
|
-
|
142
|
-
options.client_secret = oauth_config["client_secret"] if oauth_config["client_secret"]
|
143
|
-
|
144
|
-
options.scope = Array(oauth_config["scopes_supported"]).join(" ") if oauth_config["scopes_supported"]
|
145
|
-
|
146
|
-
# Enable PKCE if required (OAuth 2.1 compliance)
|
147
|
-
options.pkce = true if oauth_config["pkce_required"]
|
148
|
-
|
149
|
-
# Set userinfo endpoint if provided
|
150
|
-
options.userinfo_url = oauth_config["userinfo_endpoint"] if oauth_config["userinfo_endpoint"]
|
151
|
-
|
152
|
-
# Set token introspection endpoint
|
153
|
-
return unless oauth_config["introspection_endpoint"]
|
154
|
-
|
155
|
-
options.introspection_url = oauth_config["introspection_endpoint"]
|
156
|
-
end
|
157
|
-
end
|
158
|
-
end
|
159
|
-
end
|
160
|
-
|
161
|
-
# Register the strategy with Omniauth
|
162
|
-
OmniAuth.config.add_camelization "mcp", "MCP"
|