actionmcp 0.52.2 → 0.54.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.
@@ -49,13 +49,22 @@ module ActionMCP
49
49
  protected
50
50
 
51
51
  def authenticate!
52
- token = extract_bearer_token
53
- raise UnauthorizedError, "Missing token" unless token
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
- payload = ActionMCP::JwtDecoder.decode(token)
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
@@ -0,0 +1,112 @@
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
+ @mutex = Mutex.new
13
+ end
14
+
15
+ # Authorization code storage
16
+ def store_authorization_code(code, data)
17
+ @mutex.synchronize do
18
+ @authorization_codes[code] = data
19
+ end
20
+ end
21
+
22
+ def retrieve_authorization_code(code)
23
+ @mutex.synchronize do
24
+ @authorization_codes[code]
25
+ end
26
+ end
27
+
28
+ def remove_authorization_code(code)
29
+ @mutex.synchronize do
30
+ @authorization_codes.delete(code)
31
+ end
32
+ end
33
+
34
+ # Access token storage
35
+ def store_access_token(token, data)
36
+ @mutex.synchronize do
37
+ @access_tokens[token] = data
38
+ end
39
+ end
40
+
41
+ def retrieve_access_token(token)
42
+ @mutex.synchronize do
43
+ @access_tokens[token]
44
+ end
45
+ end
46
+
47
+ def remove_access_token(token)
48
+ @mutex.synchronize do
49
+ @access_tokens.delete(token)
50
+ end
51
+ end
52
+
53
+ # Refresh token storage
54
+ def store_refresh_token(token, data)
55
+ @mutex.synchronize do
56
+ @refresh_tokens[token] = data
57
+ end
58
+ end
59
+
60
+ def retrieve_refresh_token(token)
61
+ @mutex.synchronize do
62
+ @refresh_tokens[token]
63
+ end
64
+ end
65
+
66
+ def update_refresh_token(token, new_access_token)
67
+ @mutex.synchronize do
68
+ if @refresh_tokens[token]
69
+ @refresh_tokens[token][:access_token] = new_access_token
70
+ end
71
+ end
72
+ end
73
+
74
+ def remove_refresh_token(token)
75
+ @mutex.synchronize do
76
+ @refresh_tokens.delete(token)
77
+ end
78
+ end
79
+
80
+ # Cleanup expired tokens (optional utility method)
81
+ def cleanup_expired
82
+ current_time = Time.current
83
+
84
+ @mutex.synchronize do
85
+ @authorization_codes.reject! { |_, data| data[:expires_at] < current_time }
86
+ @access_tokens.reject! { |_, data| data[:expires_at] < current_time }
87
+ @refresh_tokens.reject! { |_, data| data[:expires_at] < current_time }
88
+ end
89
+ end
90
+
91
+ # Statistics (for debugging/monitoring)
92
+ def stats
93
+ @mutex.synchronize do
94
+ {
95
+ authorization_codes: @authorization_codes.size,
96
+ access_tokens: @access_tokens.size,
97
+ refresh_tokens: @refresh_tokens.size
98
+ }
99
+ end
100
+ end
101
+
102
+ # Clear all data (for testing)
103
+ def clear_all
104
+ @mutex.synchronize do
105
+ @authorization_codes.clear
106
+ @access_tokens.clear
107
+ @refresh_tokens.clear
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module OAuth
5
+ # OAuth middleware that integrates with Omniauth for request authentication
6
+ # Handles Bearer token validation for API requests
7
+ class Middleware
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ request = ActionDispatch::Request.new(env)
14
+
15
+ # Skip OAuth processing for non-MCP requests or if OAuth not configured
16
+ return @app.call(env) unless should_process_oauth?(request)
17
+
18
+
19
+ # Validate Bearer token for API requests
20
+ if bearer_token = extract_bearer_token(request)
21
+ validate_oauth_token(request, bearer_token)
22
+ end
23
+
24
+ @app.call(env)
25
+ rescue ActionMCP::OAuth::Error => e
26
+ oauth_error_response(e)
27
+ end
28
+
29
+ private
30
+
31
+ def should_process_oauth?(request)
32
+ # Check if OAuth is enabled in configuration
33
+ auth_methods = ActionMCP.configuration.authentication_methods
34
+ return false unless auth_methods&.include?("oauth")
35
+
36
+ # Process all MCP requests (ActionMCP serves at root "/") and OAuth-related paths
37
+ true
38
+ end
39
+
40
+
41
+ def extract_bearer_token(request)
42
+ auth_header = request.headers["Authorization"] || request.headers["authorization"]
43
+ return nil unless auth_header&.start_with?("Bearer ")
44
+
45
+ auth_header.split(" ", 2).last
46
+ end
47
+
48
+ def validate_oauth_token(request, token)
49
+ # Use the OAuth provider for token introspection
50
+ token_info = ActionMCP::OAuth::Provider.introspect_token(token)
51
+
52
+ if token_info && token_info[:active]
53
+ # Store OAuth token info in request environment for Gateway
54
+ request.env["action_mcp.oauth_token_info"] = token_info
55
+ request.env["action_mcp.oauth_token"] = token
56
+ else
57
+ raise ActionMCP::OAuth::InvalidTokenError, "Invalid or expired OAuth token"
58
+ end
59
+ end
60
+
61
+ def oauth_error_response(error)
62
+ status = case error
63
+ when ActionMCP::OAuth::InvalidTokenError
64
+ 401
65
+ when ActionMCP::OAuth::InsufficientScopeError
66
+ 403
67
+ else
68
+ 400
69
+ end
70
+
71
+ headers = {
72
+ "Content-Type" => "application/json",
73
+ "WWW-Authenticate" => www_authenticate_header(error)
74
+ }
75
+
76
+ body = {
77
+ error: error.oauth_error_code,
78
+ error_description: error.message
79
+ }.to_json
80
+
81
+ [ status, headers, [ body ] ]
82
+ end
83
+
84
+ def www_authenticate_header(error)
85
+ params = []
86
+ params << 'realm="MCP API"'
87
+
88
+ case error
89
+ when ActionMCP::OAuth::InvalidTokenError
90
+ params << 'error="invalid_token"'
91
+ when ActionMCP::OAuth::InsufficientScopeError
92
+ params << 'error="insufficient_scope"'
93
+ params << "scope=\"#{error.required_scope}\"" if error.required_scope
94
+ end
95
+
96
+ "Bearer #{params.join(', ')}"
97
+ end
98
+ end
99
+ end
100
+ end