actionmcp 0.72.0 → 0.80.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 +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/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/server/base_session.rb +2 -0
- 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 +13 -97
- 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,132 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module ActionMCP
|
4
|
-
module OAuth
|
5
|
-
# In-memory storage for OAuth tokens and codes
|
6
|
-
# This is suitable for development and testing, but not for production
|
7
|
-
class MemoryStorage
|
8
|
-
def initialize
|
9
|
-
@authorization_codes = {}
|
10
|
-
@access_tokens = {}
|
11
|
-
@refresh_tokens = {}
|
12
|
-
@client_registrations = {}
|
13
|
-
@mutex = Mutex.new
|
14
|
-
end
|
15
|
-
|
16
|
-
# Authorization code storage
|
17
|
-
def store_authorization_code(code, data)
|
18
|
-
@mutex.synchronize do
|
19
|
-
@authorization_codes[code] = data
|
20
|
-
end
|
21
|
-
end
|
22
|
-
|
23
|
-
def retrieve_authorization_code(code)
|
24
|
-
@mutex.synchronize do
|
25
|
-
@authorization_codes[code]
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
|
-
def remove_authorization_code(code)
|
30
|
-
@mutex.synchronize do
|
31
|
-
@authorization_codes.delete(code)
|
32
|
-
end
|
33
|
-
end
|
34
|
-
|
35
|
-
# Access token storage
|
36
|
-
def store_access_token(token, data)
|
37
|
-
@mutex.synchronize do
|
38
|
-
@access_tokens[token] = data
|
39
|
-
end
|
40
|
-
end
|
41
|
-
|
42
|
-
def retrieve_access_token(token)
|
43
|
-
@mutex.synchronize do
|
44
|
-
@access_tokens[token]
|
45
|
-
end
|
46
|
-
end
|
47
|
-
|
48
|
-
def remove_access_token(token)
|
49
|
-
@mutex.synchronize do
|
50
|
-
@access_tokens.delete(token)
|
51
|
-
end
|
52
|
-
end
|
53
|
-
|
54
|
-
# Refresh token storage
|
55
|
-
def store_refresh_token(token, data)
|
56
|
-
@mutex.synchronize do
|
57
|
-
@refresh_tokens[token] = data
|
58
|
-
end
|
59
|
-
end
|
60
|
-
|
61
|
-
def retrieve_refresh_token(token)
|
62
|
-
@mutex.synchronize do
|
63
|
-
@refresh_tokens[token]
|
64
|
-
end
|
65
|
-
end
|
66
|
-
|
67
|
-
def update_refresh_token(token, new_access_token)
|
68
|
-
@mutex.synchronize do
|
69
|
-
@refresh_tokens[token][:access_token] = new_access_token if @refresh_tokens[token]
|
70
|
-
end
|
71
|
-
end
|
72
|
-
|
73
|
-
def remove_refresh_token(token)
|
74
|
-
@mutex.synchronize do
|
75
|
-
@refresh_tokens.delete(token)
|
76
|
-
end
|
77
|
-
end
|
78
|
-
|
79
|
-
# Client registration storage
|
80
|
-
def store_client_registration(client_id, data)
|
81
|
-
@mutex.synchronize do
|
82
|
-
@client_registrations[client_id] = data
|
83
|
-
end
|
84
|
-
end
|
85
|
-
|
86
|
-
def retrieve_client_registration(client_id)
|
87
|
-
@mutex.synchronize do
|
88
|
-
@client_registrations[client_id]
|
89
|
-
end
|
90
|
-
end
|
91
|
-
|
92
|
-
def remove_client_registration(client_id)
|
93
|
-
@mutex.synchronize do
|
94
|
-
@client_registrations.delete(client_id)
|
95
|
-
end
|
96
|
-
end
|
97
|
-
|
98
|
-
# Cleanup expired tokens (optional utility method)
|
99
|
-
def cleanup_expired
|
100
|
-
current_time = Time.current
|
101
|
-
|
102
|
-
@mutex.synchronize do
|
103
|
-
@authorization_codes.reject! { |_, data| data[:expires_at] < current_time }
|
104
|
-
@access_tokens.reject! { |_, data| data[:expires_at] < current_time }
|
105
|
-
@refresh_tokens.reject! { |_, data| data[:expires_at] < current_time }
|
106
|
-
end
|
107
|
-
end
|
108
|
-
|
109
|
-
# Statistics (for debugging/monitoring)
|
110
|
-
def stats
|
111
|
-
@mutex.synchronize do
|
112
|
-
{
|
113
|
-
authorization_codes: @authorization_codes.size,
|
114
|
-
access_tokens: @access_tokens.size,
|
115
|
-
refresh_tokens: @refresh_tokens.size,
|
116
|
-
client_registrations: @client_registrations.size
|
117
|
-
}
|
118
|
-
end
|
119
|
-
end
|
120
|
-
|
121
|
-
# Clear all data (for testing)
|
122
|
-
def clear_all
|
123
|
-
@mutex.synchronize do
|
124
|
-
@authorization_codes.clear
|
125
|
-
@access_tokens.clear
|
126
|
-
@refresh_tokens.clear
|
127
|
-
@client_registrations.clear
|
128
|
-
end
|
129
|
-
end
|
130
|
-
end
|
131
|
-
end
|
132
|
-
end
|
@@ -1,128 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require_relative "error"
|
4
|
-
|
5
|
-
module ActionMCP
|
6
|
-
module OAuth
|
7
|
-
# OAuth middleware that integrates with Omniauth for request authentication
|
8
|
-
# Handles Bearer token validation for API requests
|
9
|
-
class Middleware
|
10
|
-
def initialize(app)
|
11
|
-
@app = app
|
12
|
-
end
|
13
|
-
|
14
|
-
def call(env)
|
15
|
-
request = ActionDispatch::Request.new(env)
|
16
|
-
|
17
|
-
# Skip OAuth processing for non-MCP requests or if OAuth not configured
|
18
|
-
return @app.call(env) unless should_process_oauth?(request)
|
19
|
-
|
20
|
-
# Skip OAuth processing for metadata endpoints
|
21
|
-
return @app.call(env) if request.path.start_with?("/.well-known/") || request.path.start_with?("/oauth/")
|
22
|
-
|
23
|
-
# Skip OAuth processing for initialization-related requests
|
24
|
-
return @app.call(env) if initialization_related_request?(request)
|
25
|
-
|
26
|
-
# Validate Bearer token for API requests
|
27
|
-
if (bearer_token = extract_bearer_token(request))
|
28
|
-
validate_oauth_token(request, bearer_token)
|
29
|
-
end
|
30
|
-
|
31
|
-
@app.call(env)
|
32
|
-
rescue ActionMCP::OAuth::Error => e
|
33
|
-
oauth_error_response(e)
|
34
|
-
end
|
35
|
-
|
36
|
-
private
|
37
|
-
|
38
|
-
def should_process_oauth?(_request)
|
39
|
-
# Check if OAuth is enabled in configuration
|
40
|
-
auth_methods = ActionMCP.configuration.authentication_methods
|
41
|
-
return false unless auth_methods&.include?("oauth")
|
42
|
-
|
43
|
-
# Process all MCP requests (ActionMCP serves at root "/") and OAuth-related paths
|
44
|
-
true
|
45
|
-
end
|
46
|
-
|
47
|
-
def initialization_related_request?(request)
|
48
|
-
# Only check JSON-RPC POST requests to MCP endpoints
|
49
|
-
# The path might include the mount path (e.g., /action_mcp/ or just /)
|
50
|
-
return false unless request.post? && request.content_type&.include?("application/json")
|
51
|
-
|
52
|
-
# Check if this is an MCP endpoint (ends with / or is the root)
|
53
|
-
path = request.path
|
54
|
-
return false unless path == "/" || path.match?(%r{/action_mcp/?$})
|
55
|
-
|
56
|
-
# Read and parse the request body
|
57
|
-
body = request.body.read
|
58
|
-
request.body.rewind # Reset for subsequent reads
|
59
|
-
|
60
|
-
json = JSON.parse(body)
|
61
|
-
method = json["method"]
|
62
|
-
|
63
|
-
# Check if it's an initialization-related method
|
64
|
-
%w[initialize notifications/initialized].include?(method)
|
65
|
-
rescue JSON::ParserError, StandardError
|
66
|
-
false
|
67
|
-
end
|
68
|
-
|
69
|
-
def extract_bearer_token(request)
|
70
|
-
auth_header = request.headers["Authorization"] || request.headers["authorization"]
|
71
|
-
return nil unless auth_header&.start_with?("Bearer ")
|
72
|
-
|
73
|
-
auth_header.split(" ", 2).last
|
74
|
-
end
|
75
|
-
|
76
|
-
def validate_oauth_token(request, token)
|
77
|
-
# Use the OAuth provider for token introspection
|
78
|
-
token_info = ActionMCP::OAuth::Provider.introspect_token(token)
|
79
|
-
|
80
|
-
unless token_info && token_info[:active]
|
81
|
-
raise ActionMCP::OAuth::InvalidTokenError, "Invalid or expired OAuth token"
|
82
|
-
end
|
83
|
-
|
84
|
-
# Store OAuth token info in request environment for Gateway
|
85
|
-
request.env["action_mcp.oauth_token_info"] = token_info
|
86
|
-
request.env["action_mcp.oauth_token"] = token
|
87
|
-
end
|
88
|
-
|
89
|
-
def oauth_error_response(error)
|
90
|
-
status = case error
|
91
|
-
when ActionMCP::OAuth::InvalidTokenError
|
92
|
-
401
|
93
|
-
when ActionMCP::OAuth::InsufficientScopeError
|
94
|
-
403
|
95
|
-
else
|
96
|
-
400
|
97
|
-
end
|
98
|
-
|
99
|
-
headers = {
|
100
|
-
"Content-Type" => "application/json",
|
101
|
-
"WWW-Authenticate" => www_authenticate_header(error)
|
102
|
-
}
|
103
|
-
|
104
|
-
body = {
|
105
|
-
error: error.oauth_error_code,
|
106
|
-
error_description: error.message
|
107
|
-
}.to_json
|
108
|
-
|
109
|
-
[ status, headers, [ body ] ]
|
110
|
-
end
|
111
|
-
|
112
|
-
def www_authenticate_header(error)
|
113
|
-
params = []
|
114
|
-
params << 'realm="MCP API"'
|
115
|
-
|
116
|
-
case error
|
117
|
-
when ActionMCP::OAuth::InvalidTokenError
|
118
|
-
params << 'error="invalid_token"'
|
119
|
-
when ActionMCP::OAuth::InsufficientScopeError
|
120
|
-
params << 'error="insufficient_scope"'
|
121
|
-
params << "scope=\"#{error.required_scope}\"" if error.required_scope
|
122
|
-
end
|
123
|
-
|
124
|
-
"Bearer #{params.join(', ')}"
|
125
|
-
end
|
126
|
-
end
|
127
|
-
end
|
128
|
-
end
|
@@ -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
|