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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 961fd09ccf447057085e6e63d38c64079b460917d48d70c5ad5dc53ee0c3154f
|
4
|
+
data.tar.gz: c74e249d91903199a65afa5ecad8ff55bf188aa7e48167742c26aa20396913cf
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0f3637e231ef5d5f668d548c41a3d71c121946b4002d56faf27b6fe1941e8f5fb2cf3ed70b0bb49a370055124777ef7300bf3bec174cfa2802c321bbd56f95b2
|
7
|
+
data.tar.gz: d1066a9642357aa64e77e0bcbdfb99205dbbbe27140262d41aa0bc61aad900e007d6da5a0a136ed116e101fba365066184bb16602cff3caed3c755ddfca8d9bc
|
data/README.md
CHANGED
@@ -493,6 +493,8 @@ This will create `config/mcp.yml` with example configurations for all environmen
|
|
493
493
|
|
494
494
|
ActionMCP provides a Gateway system similar to ActionCable's Connection for handling authentication. The Gateway allows you to authenticate users and make them available throughout your MCP components.
|
495
495
|
|
496
|
+
ActionMCP supports multiple authentication methods including OAuth 2.1, JWT tokens, and no authentication for development. For detailed OAuth 2.1 configuration and usage, see the [OAuth Authentication Guide](OAUTH.md).
|
497
|
+
|
496
498
|
### Creating an ApplicationGateway
|
497
499
|
|
498
500
|
When you run the install generator, it creates an `ApplicationGateway` class:
|
@@ -0,0 +1,264 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ostruct"
|
4
|
+
|
5
|
+
module ActionMCP
|
6
|
+
module OAuth
|
7
|
+
# OAuth 2.1 endpoints controller
|
8
|
+
# Handles authorization, token, introspection, and revocation endpoints
|
9
|
+
class EndpointsController < ActionController::Base
|
10
|
+
protect_from_forgery with: :null_session
|
11
|
+
before_action :check_oauth_enabled
|
12
|
+
|
13
|
+
# GET /oauth/authorize
|
14
|
+
# Authorization endpoint for OAuth 2.1 authorization code flow
|
15
|
+
def authorize
|
16
|
+
# Extract parameters
|
17
|
+
client_id = params[:client_id]
|
18
|
+
redirect_uri = params[:redirect_uri]
|
19
|
+
response_type = params[:response_type]
|
20
|
+
scope = params[:scope]
|
21
|
+
state = params[:state]
|
22
|
+
code_challenge = params[:code_challenge]
|
23
|
+
code_challenge_method = params[:code_challenge_method]
|
24
|
+
|
25
|
+
# Validate required parameters
|
26
|
+
if client_id.blank? || redirect_uri.blank? || response_type.blank?
|
27
|
+
return render_error("invalid_request", "Missing required parameters")
|
28
|
+
end
|
29
|
+
|
30
|
+
# Validate response type
|
31
|
+
unless response_type == "code"
|
32
|
+
return render_error("unsupported_response_type", "Only authorization code flow supported")
|
33
|
+
end
|
34
|
+
|
35
|
+
# Validate PKCE if required
|
36
|
+
if oauth_config["pkce_required"] && code_challenge.blank?
|
37
|
+
return render_error("invalid_request", "PKCE required")
|
38
|
+
end
|
39
|
+
|
40
|
+
# In a real implementation, this would show a consent page
|
41
|
+
# For now, we'll auto-approve for configured clients
|
42
|
+
if auto_approve_client?(client_id)
|
43
|
+
# Generate authorization code
|
44
|
+
user_id = current_user&.id || "anonymous"
|
45
|
+
|
46
|
+
begin
|
47
|
+
code = ActionMCP::OAuth::Provider.generate_authorization_code(
|
48
|
+
client_id: client_id,
|
49
|
+
redirect_uri: redirect_uri,
|
50
|
+
scope: scope || default_scope,
|
51
|
+
code_challenge: code_challenge,
|
52
|
+
code_challenge_method: code_challenge_method,
|
53
|
+
user_id: user_id
|
54
|
+
)
|
55
|
+
|
56
|
+
# Redirect back to client with authorization code
|
57
|
+
redirect_params = { code: code }
|
58
|
+
redirect_params[:state] = state if state
|
59
|
+
redirect_to "#{redirect_uri}?#{redirect_params.to_query}", allow_other_host: true
|
60
|
+
rescue ActionMCP::OAuth::Error => e
|
61
|
+
render_error(e.oauth_error_code, e.message)
|
62
|
+
end
|
63
|
+
else
|
64
|
+
# In production, show consent page
|
65
|
+
render_consent_page(client_id, redirect_uri, scope, state, code_challenge, code_challenge_method)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# POST /oauth/token
|
70
|
+
# Token endpoint for exchanging authorization codes and refreshing tokens
|
71
|
+
def token
|
72
|
+
grant_type = params[:grant_type]
|
73
|
+
|
74
|
+
case grant_type
|
75
|
+
when "authorization_code"
|
76
|
+
handle_authorization_code_grant
|
77
|
+
when "refresh_token"
|
78
|
+
handle_refresh_token_grant
|
79
|
+
when "client_credentials"
|
80
|
+
handle_client_credentials_grant
|
81
|
+
else
|
82
|
+
render_token_error("unsupported_grant_type", "Unsupported grant type")
|
83
|
+
end
|
84
|
+
rescue ActionMCP::OAuth::Error => e
|
85
|
+
render_token_error(e.oauth_error_code, e.message)
|
86
|
+
end
|
87
|
+
|
88
|
+
# POST /oauth/introspect
|
89
|
+
# Token introspection endpoint (RFC 7662)
|
90
|
+
def introspect
|
91
|
+
token = params[:token]
|
92
|
+
return render_introspection_error unless token
|
93
|
+
|
94
|
+
# Authenticate client for introspection
|
95
|
+
client_id, client_secret = extract_client_credentials
|
96
|
+
return render_introspection_error unless client_id
|
97
|
+
|
98
|
+
begin
|
99
|
+
token_info = ActionMCP::OAuth::Provider.introspect_token(token)
|
100
|
+
render json: token_info
|
101
|
+
rescue ActionMCP::OAuth::Error
|
102
|
+
render json: { active: false }
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# POST /oauth/revoke
|
107
|
+
# Token revocation endpoint (RFC 7009)
|
108
|
+
def revoke
|
109
|
+
token = params[:token]
|
110
|
+
token_type_hint = params[:token_type_hint]
|
111
|
+
|
112
|
+
return head :bad_request unless token
|
113
|
+
|
114
|
+
# Authenticate client
|
115
|
+
client_id, client_secret = extract_client_credentials
|
116
|
+
return head :unauthorized unless client_id
|
117
|
+
|
118
|
+
begin
|
119
|
+
ActionMCP::OAuth::Provider.revoke_token(token, token_type_hint: token_type_hint)
|
120
|
+
head :ok
|
121
|
+
rescue ActionMCP::OAuth::Error
|
122
|
+
head :bad_request
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
private
|
127
|
+
|
128
|
+
def check_oauth_enabled
|
129
|
+
auth_methods = ActionMCP.configuration.authentication_methods
|
130
|
+
unless auth_methods&.include?("oauth")
|
131
|
+
head :not_found
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def oauth_config
|
136
|
+
@oauth_config ||= ActionMCP.configuration.oauth_config || {}
|
137
|
+
end
|
138
|
+
|
139
|
+
def handle_authorization_code_grant
|
140
|
+
code = params[:code]
|
141
|
+
client_id = params[:client_id]
|
142
|
+
client_secret = params[:client_secret]
|
143
|
+
redirect_uri = params[:redirect_uri]
|
144
|
+
code_verifier = params[:code_verifier]
|
145
|
+
|
146
|
+
# Extract client credentials from Authorization header if not in params
|
147
|
+
if client_id.blank?
|
148
|
+
client_id, client_secret = extract_client_credentials
|
149
|
+
end
|
150
|
+
|
151
|
+
return render_token_error("invalid_request", "Missing required parameters") if code.blank? || client_id.blank?
|
152
|
+
|
153
|
+
token_response = ActionMCP::OAuth::Provider.exchange_code_for_token(
|
154
|
+
code: code,
|
155
|
+
client_id: client_id,
|
156
|
+
client_secret: client_secret,
|
157
|
+
redirect_uri: redirect_uri,
|
158
|
+
code_verifier: code_verifier
|
159
|
+
)
|
160
|
+
|
161
|
+
render json: token_response
|
162
|
+
end
|
163
|
+
|
164
|
+
def handle_refresh_token_grant
|
165
|
+
refresh_token = params[:refresh_token]
|
166
|
+
scope = params[:scope]
|
167
|
+
|
168
|
+
# Extract client credentials
|
169
|
+
client_id, client_secret = extract_client_credentials
|
170
|
+
client_id ||= params[:client_id]
|
171
|
+
client_secret ||= params[:client_secret]
|
172
|
+
|
173
|
+
return render_token_error("invalid_request", "Missing required parameters") if refresh_token.blank? || client_id.blank?
|
174
|
+
|
175
|
+
token_response = ActionMCP::OAuth::Provider.refresh_access_token(
|
176
|
+
refresh_token: refresh_token,
|
177
|
+
client_id: client_id,
|
178
|
+
client_secret: client_secret,
|
179
|
+
scope: scope
|
180
|
+
)
|
181
|
+
|
182
|
+
render json: token_response
|
183
|
+
end
|
184
|
+
|
185
|
+
def handle_client_credentials_grant
|
186
|
+
scope = params[:scope]
|
187
|
+
|
188
|
+
# Extract client credentials
|
189
|
+
client_id, client_secret = extract_client_credentials
|
190
|
+
client_id ||= params[:client_id]
|
191
|
+
client_secret ||= params[:client_secret]
|
192
|
+
|
193
|
+
return render_token_error("invalid_request", "Missing client credentials") if client_id.blank?
|
194
|
+
|
195
|
+
token_response = ActionMCP::OAuth::Provider.client_credentials_grant(
|
196
|
+
client_id: client_id,
|
197
|
+
client_secret: client_secret,
|
198
|
+
scope: scope
|
199
|
+
)
|
200
|
+
|
201
|
+
render json: token_response
|
202
|
+
end
|
203
|
+
|
204
|
+
def extract_client_credentials
|
205
|
+
auth_header = request.headers["Authorization"]
|
206
|
+
if auth_header&.start_with?("Basic ")
|
207
|
+
encoded = auth_header.split(" ", 2).last
|
208
|
+
decoded = Base64.decode64(encoded)
|
209
|
+
decoded.split(":", 2)
|
210
|
+
else
|
211
|
+
[ nil, nil ]
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
def auto_approve_client?(client_id)
|
216
|
+
# In development/testing, auto-approve known clients
|
217
|
+
# In production, this should check a proper client registry
|
218
|
+
Rails.env.development? || Rails.env.test? || oauth_config["auto_approve_clients"]&.include?(client_id)
|
219
|
+
end
|
220
|
+
|
221
|
+
def current_user
|
222
|
+
# This should be implemented by the application
|
223
|
+
# For now, return a default user for development
|
224
|
+
if Rails.env.development? || Rails.env.test?
|
225
|
+
OpenStruct.new(id: "dev_user", email: "dev@example.com")
|
226
|
+
else
|
227
|
+
# In production, this should integrate with your authentication system
|
228
|
+
nil
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def default_scope
|
233
|
+
oauth_config["default_scope"] || "mcp:tools mcp:resources mcp:prompts"
|
234
|
+
end
|
235
|
+
|
236
|
+
def render_error(error_code, description)
|
237
|
+
render json: {
|
238
|
+
error: error_code,
|
239
|
+
error_description: description
|
240
|
+
}, status: :bad_request
|
241
|
+
end
|
242
|
+
|
243
|
+
def render_token_error(error_code, description)
|
244
|
+
render json: {
|
245
|
+
error: error_code,
|
246
|
+
error_description: description
|
247
|
+
}, status: :bad_request
|
248
|
+
end
|
249
|
+
|
250
|
+
def render_introspection_error
|
251
|
+
render json: { active: false }, status: :bad_request
|
252
|
+
end
|
253
|
+
|
254
|
+
def render_consent_page(client_id, redirect_uri, scope, state, code_challenge, code_challenge_method)
|
255
|
+
# In production, this would render a proper consent page
|
256
|
+
# For now, just auto-deny unknown clients
|
257
|
+
render json: {
|
258
|
+
error: "access_denied",
|
259
|
+
error_description: "User denied authorization"
|
260
|
+
}, status: :forbidden
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module OAuth
|
5
|
+
# Controller for OAuth 2.1 metadata endpoints
|
6
|
+
# Provides server discovery information as per RFC 8414
|
7
|
+
class MetadataController < ActionController::Base
|
8
|
+
before_action :check_oauth_enabled
|
9
|
+
|
10
|
+
# GET /.well-known/oauth-authorization-server
|
11
|
+
# Returns OAuth Authorization Server Metadata as per RFC 8414
|
12
|
+
def authorization_server
|
13
|
+
metadata = {
|
14
|
+
issuer: issuer_url,
|
15
|
+
authorization_endpoint: authorization_endpoint,
|
16
|
+
token_endpoint: token_endpoint,
|
17
|
+
introspection_endpoint: introspection_endpoint,
|
18
|
+
revocation_endpoint: revocation_endpoint,
|
19
|
+
response_types_supported: response_types_supported,
|
20
|
+
grant_types_supported: grant_types_supported,
|
21
|
+
token_endpoint_auth_methods_supported: token_endpoint_auth_methods_supported,
|
22
|
+
scopes_supported: scopes_supported,
|
23
|
+
code_challenge_methods_supported: code_challenge_methods_supported,
|
24
|
+
service_documentation: service_documentation
|
25
|
+
}
|
26
|
+
|
27
|
+
# Add optional fields based on configuration
|
28
|
+
if oauth_config["enable_dynamic_registration"]
|
29
|
+
metadata[:registration_endpoint] = registration_endpoint
|
30
|
+
end
|
31
|
+
|
32
|
+
if oauth_config["jwks_uri"]
|
33
|
+
metadata[:jwks_uri] = oauth_config["jwks_uri"]
|
34
|
+
end
|
35
|
+
|
36
|
+
render json: metadata
|
37
|
+
end
|
38
|
+
|
39
|
+
# GET /.well-known/oauth-protected-resource
|
40
|
+
# Returns Protected Resource Metadata as per RFC 8705
|
41
|
+
def protected_resource
|
42
|
+
metadata = {
|
43
|
+
resource: issuer_url,
|
44
|
+
authorization_servers: [ issuer_url ],
|
45
|
+
scopes_supported: scopes_supported,
|
46
|
+
bearer_methods_supported: [ "header" ],
|
47
|
+
resource_documentation: resource_documentation
|
48
|
+
}
|
49
|
+
|
50
|
+
render json: metadata
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def check_oauth_enabled
|
56
|
+
auth_methods = ActionMCP.configuration.authentication_methods
|
57
|
+
unless auth_methods&.include?("oauth")
|
58
|
+
head :not_found
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def oauth_config
|
63
|
+
@oauth_config ||= ActionMCP.configuration.oauth_config || {}
|
64
|
+
end
|
65
|
+
|
66
|
+
def issuer_url
|
67
|
+
@issuer_url ||= oauth_config["issuer_url"] || request.base_url
|
68
|
+
end
|
69
|
+
|
70
|
+
def authorization_endpoint
|
71
|
+
"#{issuer_url}/oauth/authorize"
|
72
|
+
end
|
73
|
+
|
74
|
+
def token_endpoint
|
75
|
+
"#{issuer_url}/oauth/token"
|
76
|
+
end
|
77
|
+
|
78
|
+
def introspection_endpoint
|
79
|
+
"#{issuer_url}/oauth/introspect"
|
80
|
+
end
|
81
|
+
|
82
|
+
def revocation_endpoint
|
83
|
+
"#{issuer_url}/oauth/revoke"
|
84
|
+
end
|
85
|
+
|
86
|
+
def registration_endpoint
|
87
|
+
"#{issuer_url}/oauth/register"
|
88
|
+
end
|
89
|
+
|
90
|
+
def response_types_supported
|
91
|
+
[ "code" ]
|
92
|
+
end
|
93
|
+
|
94
|
+
def grant_types_supported
|
95
|
+
grants = [ "authorization_code" ]
|
96
|
+
grants << "refresh_token" if oauth_config["enable_refresh_tokens"]
|
97
|
+
grants << "client_credentials" if oauth_config["enable_client_credentials"]
|
98
|
+
grants
|
99
|
+
end
|
100
|
+
|
101
|
+
def token_endpoint_auth_methods_supported
|
102
|
+
methods = [ "client_secret_basic", "client_secret_post" ]
|
103
|
+
methods << "none" if oauth_config["allow_public_clients"]
|
104
|
+
methods
|
105
|
+
end
|
106
|
+
|
107
|
+
def scopes_supported
|
108
|
+
oauth_config["scopes_supported"] || [ "mcp:tools", "mcp:resources", "mcp:prompts" ]
|
109
|
+
end
|
110
|
+
|
111
|
+
def code_challenge_methods_supported
|
112
|
+
methods = []
|
113
|
+
if oauth_config["pkce_required"] || oauth_config["pkce_supported"]
|
114
|
+
methods << "S256"
|
115
|
+
methods << "plain" if oauth_config["allow_plain_pkce"]
|
116
|
+
end
|
117
|
+
methods
|
118
|
+
end
|
119
|
+
|
120
|
+
def service_documentation
|
121
|
+
oauth_config["service_documentation"] || "#{request.base_url}/docs"
|
122
|
+
end
|
123
|
+
|
124
|
+
def resource_documentation
|
125
|
+
oauth_config["resource_documentation"] || "#{request.base_url}/docs/api"
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
@@ -5,11 +5,16 @@
|
|
5
5
|
# Table name: action_mcp_sessions
|
6
6
|
#
|
7
7
|
# id :string not null, primary key
|
8
|
+
# authentication_method :string default("none")
|
8
9
|
# client_capabilities(The capabilities of the client) :jsonb
|
9
10
|
# client_info(The information about the client) :jsonb
|
10
11
|
# ended_at(The time the session ended) :datetime
|
11
12
|
# initialized :boolean default(FALSE), not null
|
12
13
|
# messages_count :integer default(0), not null
|
14
|
+
# oauth_access_token :string
|
15
|
+
# oauth_refresh_token :string
|
16
|
+
# oauth_token_expires_at :datetime
|
17
|
+
# oauth_user_context :jsonb
|
13
18
|
# prompt_registry :jsonb
|
14
19
|
# protocol_version :string
|
15
20
|
# resource_registry :jsonb
|
@@ -22,6 +27,12 @@
|
|
22
27
|
# created_at :datetime not null
|
23
28
|
# updated_at :datetime not null
|
24
29
|
#
|
30
|
+
# Indexes
|
31
|
+
#
|
32
|
+
# index_action_mcp_sessions_on_authentication_method (authentication_method)
|
33
|
+
# index_action_mcp_sessions_on_oauth_access_token (oauth_access_token) UNIQUE
|
34
|
+
# index_action_mcp_sessions_on_oauth_token_expires_at (oauth_token_expires_at)
|
35
|
+
#
|
25
36
|
module ActionMCP
|
26
37
|
##
|
27
38
|
# Represents an MCP session, which is a connection between a client and a server.
|
@@ -323,6 +334,94 @@ module ActionMCP
|
|
323
334
|
end
|
324
335
|
end
|
325
336
|
|
337
|
+
# OAuth Session Management
|
338
|
+
# Required by MCP 2025-03-26 specification for session binding
|
339
|
+
|
340
|
+
# Store OAuth token and user context in session
|
341
|
+
def store_oauth_token(access_token:, refresh_token: nil, expires_at:, user_context: {})
|
342
|
+
update!(
|
343
|
+
oauth_access_token: access_token,
|
344
|
+
oauth_refresh_token: refresh_token,
|
345
|
+
oauth_token_expires_at: expires_at,
|
346
|
+
oauth_user_context: user_context,
|
347
|
+
authentication_method: "oauth"
|
348
|
+
)
|
349
|
+
end
|
350
|
+
|
351
|
+
# Retrieve OAuth token information
|
352
|
+
def oauth_token_info
|
353
|
+
return nil unless oauth_access_token
|
354
|
+
|
355
|
+
{
|
356
|
+
access_token: oauth_access_token,
|
357
|
+
refresh_token: oauth_refresh_token,
|
358
|
+
expires_at: oauth_token_expires_at,
|
359
|
+
user_context: oauth_user_context || {},
|
360
|
+
authentication_method: authentication_method
|
361
|
+
}
|
362
|
+
end
|
363
|
+
|
364
|
+
# Check if OAuth token is valid and not expired
|
365
|
+
def oauth_token_valid?
|
366
|
+
return false unless oauth_access_token
|
367
|
+
return true unless oauth_token_expires_at
|
368
|
+
|
369
|
+
oauth_token_expires_at > Time.current
|
370
|
+
end
|
371
|
+
|
372
|
+
# Clear OAuth token data
|
373
|
+
def clear_oauth_token!
|
374
|
+
update!(
|
375
|
+
oauth_access_token: nil,
|
376
|
+
oauth_refresh_token: nil,
|
377
|
+
oauth_token_expires_at: nil,
|
378
|
+
oauth_user_context: nil,
|
379
|
+
authentication_method: "none"
|
380
|
+
)
|
381
|
+
end
|
382
|
+
|
383
|
+
# Update OAuth token (for refresh flow)
|
384
|
+
def update_oauth_token(access_token:, refresh_token: nil, expires_at:)
|
385
|
+
update!(
|
386
|
+
oauth_access_token: access_token,
|
387
|
+
oauth_refresh_token: refresh_token,
|
388
|
+
oauth_token_expires_at: expires_at
|
389
|
+
)
|
390
|
+
end
|
391
|
+
|
392
|
+
# Get user information from OAuth context
|
393
|
+
def oauth_user
|
394
|
+
return nil unless oauth_user_context.is_a?(Hash)
|
395
|
+
|
396
|
+
OpenStruct.new(oauth_user_context)
|
397
|
+
end
|
398
|
+
|
399
|
+
# Check if session is authenticated via OAuth
|
400
|
+
def oauth_authenticated?
|
401
|
+
authentication_method == "oauth" && oauth_token_valid?
|
402
|
+
end
|
403
|
+
|
404
|
+
# Find session by OAuth access token (class method)
|
405
|
+
def self.find_by_oauth_token(access_token)
|
406
|
+
find_by(oauth_access_token: access_token)
|
407
|
+
end
|
408
|
+
|
409
|
+
# Find sessions with expired OAuth tokens (class method)
|
410
|
+
def self.with_expired_oauth_tokens
|
411
|
+
where("oauth_token_expires_at IS NOT NULL AND oauth_token_expires_at < ?", Time.current)
|
412
|
+
end
|
413
|
+
|
414
|
+
# Cleanup expired OAuth tokens (class method)
|
415
|
+
def self.cleanup_expired_oauth_tokens
|
416
|
+
with_expired_oauth_tokens.update_all(
|
417
|
+
oauth_access_token: nil,
|
418
|
+
oauth_refresh_token: nil,
|
419
|
+
oauth_token_expires_at: nil,
|
420
|
+
oauth_user_context: nil,
|
421
|
+
authentication_method: "none"
|
422
|
+
)
|
423
|
+
end
|
424
|
+
|
326
425
|
private
|
327
426
|
|
328
427
|
# if this session is from a server, the writer is the client
|
data/config/routes.rb
CHANGED
@@ -3,6 +3,16 @@
|
|
3
3
|
ActionMCP::Engine.routes.draw do
|
4
4
|
get "/up", to: "/rails/health#show", as: :action_mcp_health_check
|
5
5
|
|
6
|
+
# OAuth 2.1 metadata endpoints
|
7
|
+
get "/.well-known/oauth-authorization-server", to: "oauth/metadata#authorization_server", as: :oauth_authorization_server_metadata
|
8
|
+
get "/.well-known/oauth-protected-resource", to: "oauth/metadata#protected_resource", as: :oauth_protected_resource_metadata
|
9
|
+
|
10
|
+
# OAuth 2.1 endpoints
|
11
|
+
get "/oauth/authorize", to: "oauth/endpoints#authorize", as: :oauth_authorize
|
12
|
+
post "/oauth/token", to: "oauth/endpoints#token", as: :oauth_token
|
13
|
+
post "/oauth/introspect", to: "oauth/endpoints#introspect", as: :oauth_introspect
|
14
|
+
post "/oauth/revoke", to: "oauth/endpoints#revoke", as: :oauth_revoke
|
15
|
+
|
6
16
|
# MCP 2025-03-26 Spec routes
|
7
17
|
get "/", to: "application#show", as: :mcp_get
|
8
18
|
post "/", to: "application#create", as: :mcp_post
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class AddOAuthToSessions < ActiveRecord::Migration[8.0]
|
4
|
+
def change
|
5
|
+
# Use jsonb for PostgreSQL, json for other databases (SQLite3, MySQL)
|
6
|
+
json_type = connection.adapter_name.downcase == 'postgresql' ? :jsonb : :json
|
7
|
+
|
8
|
+
add_column :action_mcp_sessions, :oauth_access_token, :string
|
9
|
+
add_column :action_mcp_sessions, :oauth_refresh_token, :string
|
10
|
+
add_column :action_mcp_sessions, :oauth_token_expires_at, :datetime
|
11
|
+
add_column :action_mcp_sessions, :oauth_user_context, json_type
|
12
|
+
add_column :action_mcp_sessions, :authentication_method, :string, default: "none"
|
13
|
+
|
14
|
+
# Add indexes for performance
|
15
|
+
add_index :action_mcp_sessions, :oauth_access_token, unique: true
|
16
|
+
add_index :action_mcp_sessions, :oauth_token_expires_at
|
17
|
+
add_index :action_mcp_sessions, :authentication_method
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module Client
|
5
|
+
class OauthClientProvider
|
6
|
+
# Simple in-memory storage for development
|
7
|
+
# In production, use persistent storage
|
8
|
+
class MemoryStorage
|
9
|
+
def initialize
|
10
|
+
@data = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
def save_tokens(tokens)
|
14
|
+
@data[:tokens] = tokens
|
15
|
+
end
|
16
|
+
|
17
|
+
def load_tokens
|
18
|
+
@data[:tokens]
|
19
|
+
end
|
20
|
+
|
21
|
+
def clear_tokens
|
22
|
+
@data.delete(:tokens)
|
23
|
+
end
|
24
|
+
|
25
|
+
def save_code_verifier(verifier)
|
26
|
+
@data[:code_verifier] = verifier
|
27
|
+
end
|
28
|
+
|
29
|
+
def load_code_verifier
|
30
|
+
@data[:code_verifier]
|
31
|
+
end
|
32
|
+
|
33
|
+
def clear_code_verifier
|
34
|
+
@data.delete(:code_verifier)
|
35
|
+
end
|
36
|
+
|
37
|
+
def save_client_information(info)
|
38
|
+
@data[:client_information] = info
|
39
|
+
end
|
40
|
+
|
41
|
+
def load_client_information
|
42
|
+
@data[:client_information]
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|