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.
@@ -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
@@ -0,0 +1,390 @@
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:, code_challenge: nil, code_challenge_method: nil, user_id:)
22
+ # Validate scope
23
+ validate_scope(scope) if scope
24
+
25
+ code = SecureRandom.urlsafe_base64(32)
26
+
27
+ # Store authorization code with metadata
28
+ store_authorization_code(code, {
29
+ client_id: client_id,
30
+ redirect_uri: redirect_uri,
31
+ scope: scope,
32
+ code_challenge: code_challenge,
33
+ code_challenge_method: code_challenge_method,
34
+ user_id: user_id,
35
+ created_at: Time.current,
36
+ expires_at: 10.minutes.from_now
37
+ })
38
+
39
+ code
40
+ end
41
+
42
+ # Exchange authorization code for access token
43
+ # @param code [String] Authorization code
44
+ # @param client_id [String] OAuth client identifier
45
+ # @param client_secret [String] OAuth client secret (optional for public clients)
46
+ # @param redirect_uri [String] Client redirect URI
47
+ # @param code_verifier [String] PKCE code verifier
48
+ # @return [Hash] Token response with access_token, token_type, expires_in, scope
49
+ def exchange_code_for_token(code:, client_id:, client_secret: nil, redirect_uri:, code_verifier: nil)
50
+ # Retrieve and validate authorization code
51
+ code_data = retrieve_authorization_code(code)
52
+ raise InvalidGrantError, "Invalid authorization code" unless code_data
53
+ raise InvalidGrantError, "Authorization code expired" if code_data[:expires_at] < Time.current
54
+
55
+ # Validate client
56
+ validate_client(client_id, client_secret)
57
+
58
+ # Validate redirect URI matches
59
+ unless code_data[:redirect_uri] == redirect_uri
60
+ raise InvalidGrantError, "Redirect URI mismatch"
61
+ end
62
+
63
+ # Validate client ID matches
64
+ unless code_data[:client_id] == client_id
65
+ raise InvalidGrantError, "Client ID mismatch"
66
+ end
67
+
68
+ # Validate PKCE if challenge was provided during authorization
69
+ if code_data[:code_challenge]
70
+ validate_pkce(code_data[:code_challenge], code_data[:code_challenge_method], code_verifier)
71
+ end
72
+
73
+ # Generate access token
74
+ access_token = generate_access_token(
75
+ client_id: client_id,
76
+ scope: code_data[:scope],
77
+ user_id: code_data[:user_id]
78
+ )
79
+
80
+ # Generate refresh token if enabled
81
+ refresh_token = nil
82
+ if oauth_config["enable_refresh_tokens"]
83
+ refresh_token = generate_refresh_token(
84
+ client_id: client_id,
85
+ scope: code_data[:scope],
86
+ user_id: code_data[:user_id],
87
+ access_token: access_token
88
+ )
89
+ end
90
+
91
+ # Remove used authorization code
92
+ remove_authorization_code(code)
93
+
94
+ # Return token response
95
+ response = {
96
+ access_token: access_token,
97
+ token_type: "Bearer",
98
+ expires_in: token_expires_in,
99
+ scope: code_data[:scope]
100
+ }
101
+ response[:refresh_token] = refresh_token if refresh_token
102
+ response
103
+ end
104
+
105
+ # Refresh access token using refresh token
106
+ # @param refresh_token [String] Refresh token
107
+ # @param client_id [String] OAuth client identifier
108
+ # @param client_secret [String] OAuth client secret
109
+ # @param scope [String] Requested scope (optional, must be subset of original)
110
+ # @return [Hash] New token response
111
+ def refresh_access_token(refresh_token:, client_id:, client_secret: nil, scope: nil)
112
+ # Retrieve refresh token data
113
+ token_data = retrieve_refresh_token(refresh_token)
114
+ raise InvalidGrantError, "Invalid refresh token" unless token_data
115
+ raise InvalidGrantError, "Refresh token expired" if token_data[:expires_at] < Time.current
116
+
117
+ # Validate client
118
+ validate_client(client_id, client_secret)
119
+
120
+ # Validate client ID matches
121
+ unless token_data[:client_id] == client_id
122
+ raise InvalidGrantError, "Client ID mismatch"
123
+ end
124
+
125
+ # Validate scope if provided
126
+ if scope
127
+ requested_scopes = scope.split(" ")
128
+ original_scopes = token_data[:scope].split(" ")
129
+ unless (requested_scopes - original_scopes).empty?
130
+ raise InvalidScopeError, "Requested scope exceeds original scope"
131
+ end
132
+ else
133
+ scope = token_data[:scope]
134
+ end
135
+
136
+ # Revoke old access token
137
+ revoke_access_token(token_data[:access_token]) if token_data[:access_token]
138
+
139
+ # Generate new access token
140
+ access_token = generate_access_token(
141
+ client_id: client_id,
142
+ scope: scope,
143
+ user_id: token_data[:user_id]
144
+ )
145
+
146
+ # Update refresh token with new access token
147
+ update_refresh_token(refresh_token, access_token)
148
+
149
+ {
150
+ access_token: access_token,
151
+ token_type: "Bearer",
152
+ expires_in: token_expires_in,
153
+ scope: scope
154
+ }
155
+ end
156
+
157
+ # Validate access token and return token info
158
+ # @param access_token [String] Access token to validate
159
+ # @return [Hash] Token info with active, client_id, scope, user_id, exp
160
+ def introspect_token(access_token)
161
+ token_data = retrieve_access_token(access_token)
162
+
163
+ unless token_data
164
+ return { active: false }
165
+ end
166
+
167
+ if token_data[:expires_at] < Time.current
168
+ remove_access_token(access_token)
169
+ return { active: false }
170
+ end
171
+
172
+ {
173
+ active: true,
174
+ client_id: token_data[:client_id],
175
+ scope: token_data[:scope],
176
+ user_id: token_data[:user_id],
177
+ exp: token_data[:expires_at].to_i,
178
+ iat: token_data[:created_at].to_i,
179
+ token_type: "Bearer"
180
+ }
181
+ end
182
+
183
+ # Revoke access or refresh token
184
+ # @param token [String] Token to revoke
185
+ # @param token_type_hint [String] Type hint: "access_token" or "refresh_token"
186
+ # @return [Boolean] True if token was revoked
187
+ def revoke_token(token, token_type_hint: nil)
188
+ revoked = false
189
+
190
+ # Try access token first if hint suggests it or no hint provided
191
+ if token_type_hint == "access_token" || token_type_hint.nil?
192
+ if retrieve_access_token(token)
193
+ revoke_access_token(token)
194
+ revoked = true
195
+ end
196
+ end
197
+
198
+ # Try refresh token if not revoked yet
199
+ if !revoked && (token_type_hint == "refresh_token" || token_type_hint.nil?)
200
+ if retrieve_refresh_token(token)
201
+ revoke_refresh_token(token)
202
+ revoked = true
203
+ end
204
+ end
205
+
206
+ revoked
207
+ end
208
+
209
+ # Client Credentials Grant (for server-to-server)
210
+ # @param client_id [String] OAuth client identifier
211
+ # @param client_secret [String] OAuth client secret
212
+ # @param scope [String] Requested scope
213
+ # @return [Hash] Token response
214
+ def client_credentials_grant(client_id:, client_secret:, scope: nil)
215
+ unless oauth_config["enable_client_credentials"]
216
+ raise UnsupportedGrantTypeError, "Client credentials grant not supported"
217
+ end
218
+
219
+ # Validate client credentials
220
+ validate_client(client_id, client_secret, require_secret: true)
221
+
222
+ # Validate scope
223
+ if scope
224
+ validate_scope(scope)
225
+ else
226
+ scope = default_scope
227
+ end
228
+
229
+ # Generate access token (no user context for client credentials)
230
+ access_token = generate_access_token(
231
+ client_id: client_id,
232
+ scope: scope,
233
+ user_id: nil
234
+ )
235
+
236
+ {
237
+ access_token: access_token,
238
+ token_type: "Bearer",
239
+ expires_in: token_expires_in,
240
+ scope: scope
241
+ }
242
+ end
243
+
244
+ private
245
+
246
+ def oauth_config
247
+ @oauth_config ||= ActionMCP.configuration.oauth_config || {}
248
+ end
249
+
250
+ def validate_client(client_id, client_secret, require_secret: false)
251
+ # This should be implemented by the application
252
+ # For now, we'll use a simple validation approach
253
+ provider_class = oauth_config["provider"]
254
+ if provider_class && provider_class.respond_to?(:validate_client)
255
+ provider_class.validate_client(client_id, client_secret)
256
+ elsif require_secret && client_secret.nil?
257
+ raise InvalidClientError, "Client authentication required"
258
+ end
259
+ # Default: allow any client for development
260
+ end
261
+
262
+ def validate_pkce(code_challenge, method, code_verifier)
263
+ raise InvalidGrantError, "Code verifier required" unless code_verifier
264
+
265
+ case method
266
+ when "S256"
267
+ expected_challenge = Base64.urlsafe_encode64(
268
+ Digest::SHA256.digest(code_verifier), padding: false
269
+ )
270
+ unless code_challenge == expected_challenge
271
+ raise InvalidGrantError, "Invalid code verifier"
272
+ end
273
+ when "plain"
274
+ unless oauth_config["allow_plain_pkce"]
275
+ raise InvalidGrantError, "Plain PKCE not allowed"
276
+ end
277
+ unless code_challenge == code_verifier
278
+ raise InvalidGrantError, "Invalid code verifier"
279
+ end
280
+ else
281
+ raise InvalidGrantError, "Unsupported code challenge method"
282
+ end
283
+ end
284
+
285
+ def validate_scope(scope)
286
+ supported_scopes = oauth_config["scopes_supported"] || [ "mcp:tools", "mcp:resources", "mcp:prompts" ]
287
+ requested_scopes = scope.split(" ")
288
+ unsupported = requested_scopes - supported_scopes
289
+ if unsupported.any?
290
+ raise InvalidScopeError, "Unsupported scopes: #{unsupported.join(', ')}"
291
+ end
292
+ end
293
+
294
+ def default_scope
295
+ oauth_config["default_scope"] || "mcp:tools mcp:resources mcp:prompts"
296
+ end
297
+
298
+ def generate_access_token(client_id:, scope:, user_id:)
299
+ token = SecureRandom.urlsafe_base64(32)
300
+
301
+ store_access_token(token, {
302
+ client_id: client_id,
303
+ scope: scope,
304
+ user_id: user_id,
305
+ created_at: Time.current,
306
+ expires_at: token_expires_in.seconds.from_now
307
+ })
308
+
309
+ token
310
+ end
311
+
312
+ def generate_refresh_token(client_id:, scope:, user_id:, access_token:)
313
+ token = SecureRandom.urlsafe_base64(32)
314
+
315
+ store_refresh_token(token, {
316
+ client_id: client_id,
317
+ scope: scope,
318
+ user_id: user_id,
319
+ access_token: access_token,
320
+ created_at: Time.current,
321
+ expires_at: refresh_token_expires_in.seconds.from_now
322
+ })
323
+
324
+ token
325
+ end
326
+
327
+ def token_expires_in
328
+ oauth_config["access_token_expires_in"] || 3600 # 1 hour
329
+ end
330
+
331
+ def refresh_token_expires_in
332
+ oauth_config["refresh_token_expires_in"] || 7.days.to_i # 1 week
333
+ end
334
+
335
+ # Storage methods - these delegate to a configurable storage backend
336
+ def storage
337
+ @storage ||= begin
338
+ storage_class = oauth_config["storage"] || "ActionMCP::OAuth::MemoryStorage"
339
+ storage_class = storage_class.constantize if storage_class.is_a?(String)
340
+ storage_class.new
341
+ end
342
+ end
343
+
344
+ def store_authorization_code(code, data)
345
+ storage.store_authorization_code(code, data)
346
+ end
347
+
348
+ def retrieve_authorization_code(code)
349
+ storage.retrieve_authorization_code(code)
350
+ end
351
+
352
+ def remove_authorization_code(code)
353
+ storage.remove_authorization_code(code)
354
+ end
355
+
356
+ def store_access_token(token, data)
357
+ storage.store_access_token(token, data)
358
+ end
359
+
360
+ def retrieve_access_token(token)
361
+ storage.retrieve_access_token(token)
362
+ end
363
+
364
+ def remove_access_token(token)
365
+ storage.remove_access_token(token)
366
+ end
367
+
368
+ def revoke_access_token(token)
369
+ storage.remove_access_token(token)
370
+ end
371
+
372
+ def store_refresh_token(token, data)
373
+ storage.store_refresh_token(token, data)
374
+ end
375
+
376
+ def retrieve_refresh_token(token)
377
+ storage.retrieve_refresh_token(token)
378
+ end
379
+
380
+ def update_refresh_token(token, new_access_token)
381
+ storage.update_refresh_token(token, new_access_token)
382
+ end
383
+
384
+ def revoke_refresh_token(token)
385
+ storage.remove_refresh_token(token)
386
+ end
387
+ end
388
+ end
389
+ end
390
+ end