ruby_llm-mcp 0.7.1 → 0.8.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
  3. data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
  4. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
  5. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
  6. data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
  7. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
  8. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
  9. data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
  10. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
  11. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
  12. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
  13. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
  14. data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
  15. data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
  16. data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
  17. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
  18. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +115 -0
  19. data/lib/ruby_llm/mcp/auth/browser/opener.rb +41 -0
  20. data/lib/ruby_llm/mcp/auth/browser/pages.rb +539 -0
  21. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +254 -0
  22. data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
  23. data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
  24. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
  25. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
  26. data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
  27. data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
  28. data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
  29. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +65 -0
  30. data/lib/ruby_llm/mcp/auth/memory_storage.rb +72 -0
  31. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +226 -0
  32. data/lib/ruby_llm/mcp/auth/security.rb +44 -0
  33. data/lib/ruby_llm/mcp/auth/session_manager.rb +56 -0
  34. data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
  35. data/lib/ruby_llm/mcp/auth/url_builder.rb +78 -0
  36. data/lib/ruby_llm/mcp/auth.rb +359 -0
  37. data/lib/ruby_llm/mcp/client.rb +49 -0
  38. data/lib/ruby_llm/mcp/configuration.rb +39 -13
  39. data/lib/ruby_llm/mcp/coordinator.rb +11 -0
  40. data/lib/ruby_llm/mcp/errors.rb +11 -0
  41. data/lib/ruby_llm/mcp/railtie.rb +2 -10
  42. data/lib/ruby_llm/mcp/tool.rb +1 -1
  43. data/lib/ruby_llm/mcp/transport.rb +94 -1
  44. data/lib/ruby_llm/mcp/transports/sse.rb +116 -22
  45. data/lib/ruby_llm/mcp/transports/stdio.rb +4 -3
  46. data/lib/ruby_llm/mcp/transports/streamable_http.rb +81 -79
  47. data/lib/ruby_llm/mcp/version.rb +1 -1
  48. data/lib/ruby_llm/mcp.rb +10 -4
  49. metadata +40 -5
  50. /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +0 -0
  51. /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/mcps.yml +0 -0
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module Flows
7
+ # Orchestrates OAuth 2.1 Client Credentials flow
8
+ # Used for application authentication without user interaction
9
+ class ClientCredentialsFlow
10
+ attr_reader :discoverer, :client_registrar, :token_manager, :storage, :logger
11
+
12
+ def initialize(discoverer:, client_registrar:, token_manager:, storage:, logger:)
13
+ @discoverer = discoverer
14
+ @client_registrar = client_registrar
15
+ @token_manager = token_manager
16
+ @storage = storage
17
+ @logger = logger
18
+ end
19
+
20
+ # Perform client credentials flow
21
+ # @param server_url [String] MCP server URL
22
+ # @param redirect_uri [String] redirect URI (used for registration only)
23
+ # @param scope [String, nil] requested scope
24
+ # @return [Token] access token
25
+ def execute(server_url, redirect_uri, scope)
26
+ logger.debug("Starting OAuth client credentials flow")
27
+
28
+ # 1. Discover authorization server
29
+ server_metadata = discoverer.discover(server_url)
30
+ raise Errors::TransportError.new(message: "OAuth server discovery failed") unless server_metadata
31
+
32
+ # 2. Register client (or get cached client) with client credentials grant
33
+ client_info = client_registrar.get_or_register(
34
+ server_url,
35
+ server_metadata,
36
+ :client_credentials,
37
+ redirect_uri,
38
+ scope
39
+ )
40
+
41
+ # 3. Validate that we have a client secret
42
+ unless client_info.client_secret
43
+ raise Errors::TransportError.new(
44
+ message: "Client credentials flow requires client_secret"
45
+ )
46
+ end
47
+
48
+ # 4. Exchange client credentials for token
49
+ token = token_manager.exchange_client_credentials(
50
+ server_metadata,
51
+ client_info,
52
+ scope,
53
+ server_url
54
+ )
55
+
56
+ # 5. Store token
57
+ storage.set_token(server_url, token)
58
+
59
+ logger.info("Client credentials authentication completed successfully")
60
+ token
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module GrantStrategies
7
+ # Authorization Code grant strategy
8
+ # Used for user authorization with PKCE (OAuth 2.1)
9
+ class AuthorizationCode < Base
10
+ # Public clients don't use client_secret
11
+ # @return [String] "none"
12
+ def auth_method
13
+ "none"
14
+ end
15
+
16
+ # Authorization code and refresh token grants
17
+ # @return [Array<String>] grant types
18
+ def grant_types_list
19
+ %w[authorization_code refresh_token]
20
+ end
21
+
22
+ # Only "code" response type for authorization code flow
23
+ # @return [Array<String>] response types
24
+ def response_types_list
25
+ ["code"]
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module GrantStrategies
7
+ # Base strategy for OAuth grant types
8
+ # Defines interface for grant-specific configuration
9
+ class Base
10
+ # Get token endpoint authentication method
11
+ # @return [String] auth method (e.g., "none", "client_secret_post")
12
+ def auth_method
13
+ raise NotImplementedError, "#{self.class} must implement #auth_method"
14
+ end
15
+
16
+ # Get list of grant types to request during registration
17
+ # @return [Array<String>] grant types
18
+ def grant_types_list
19
+ raise NotImplementedError, "#{self.class} must implement #grant_types_list"
20
+ end
21
+
22
+ # Get list of response types to request during registration
23
+ # @return [Array<String>] response types
24
+ def response_types_list
25
+ raise NotImplementedError, "#{self.class} must implement #response_types_list"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module GrantStrategies
7
+ # Client Credentials grant strategy
8
+ # Used for application authentication without user interaction
9
+ class ClientCredentials < Base
10
+ # Client credentials require client_secret
11
+ # @return [String] "client_secret_post"
12
+ def auth_method
13
+ "client_secret_post"
14
+ end
15
+
16
+ # Client credentials and refresh token grants
17
+ # @return [Array<String>] grant types
18
+ def grant_types_list
19
+ %w[client_credentials refresh_token]
20
+ end
21
+
22
+ # No response types for client credentials flow (no redirect)
23
+ # @return [Array<String>] response types (empty)
24
+ def response_types_list
25
+ []
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubyLLM
6
+ module MCP
7
+ module Auth
8
+ # Utility class for handling HTTP responses in OAuth flows
9
+ # Consolidates error handling and response parsing
10
+ class HttpResponseHandler
11
+ # Handle and parse a successful HTTP response
12
+ # @param response [HTTPX::Response, HTTPX::ErrorResponse] HTTP response
13
+ # @param context [String] description for error messages (e.g., "Token exchange")
14
+ # @param expected_status [Integer, Array<Integer>] expected status code(s)
15
+ # @return [Hash] parsed JSON response
16
+ # @raise [Errors::TransportError] if response is an error or unexpected status
17
+ def self.handle_response(response, context:, expected_status: 200)
18
+ expected_statuses = Array(expected_status)
19
+
20
+ # Handle HTTPX ErrorResponse (connection failures, timeouts, etc.)
21
+ if response.is_a?(HTTPX::ErrorResponse)
22
+ error_message = response.error&.message || "Request failed"
23
+ raise Errors::TransportError.new(
24
+ message: "#{context} failed: #{error_message}"
25
+ )
26
+ end
27
+
28
+ unless expected_statuses.include?(response.status)
29
+ raise Errors::TransportError.new(
30
+ message: "#{context} failed: HTTP #{response.status}",
31
+ code: response.status
32
+ )
33
+ end
34
+
35
+ JSON.parse(response.body.to_s)
36
+ end
37
+
38
+ # Extract redirect URI mismatch details from error response
39
+ # @param body [String] error response body
40
+ # @return [Hash, nil] mismatch details or nil
41
+ def self.extract_redirect_mismatch(body)
42
+ data = JSON.parse(body)
43
+ error = data["error"] || data[:error]
44
+ return nil unless error == "unauthorized_client"
45
+
46
+ description = data["error_description"] || data[:error_description]
47
+ return nil unless description.is_a?(String)
48
+
49
+ # Parse common OAuth error message format
50
+ # Matches: "You sent <url> and we expected <url>"
51
+ match = description.match(%r{You sent\s+(https?://[^\s,]+)[\s,]+and we expected\s+(https?://\S+?)\.?\s*$}i)
52
+ return nil unless match
53
+
54
+ {
55
+ sent: match[1],
56
+ expected: match[2],
57
+ description: description
58
+ }
59
+ rescue JSON::ParserError
60
+ nil
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ # In-memory storage for OAuth data
7
+ # Stores tokens, client registrations, server metadata, and temporary session data
8
+ class MemoryStorage
9
+ def initialize
10
+ @tokens = {}
11
+ @client_infos = {}
12
+ @server_metadata = {}
13
+ @pkce_data = {}
14
+ @state_data = {}
15
+ end
16
+
17
+ # Token storage
18
+ def get_token(server_url)
19
+ @tokens[server_url]
20
+ end
21
+
22
+ def set_token(server_url, token)
23
+ @tokens[server_url] = token
24
+ end
25
+
26
+ # Client registration storage
27
+ def get_client_info(server_url)
28
+ @client_infos[server_url]
29
+ end
30
+
31
+ def set_client_info(server_url, client_info)
32
+ @client_infos[server_url] = client_info
33
+ end
34
+
35
+ # Server metadata caching
36
+ def get_server_metadata(server_url)
37
+ @server_metadata[server_url]
38
+ end
39
+
40
+ def set_server_metadata(server_url, metadata)
41
+ @server_metadata[server_url] = metadata
42
+ end
43
+
44
+ # PKCE state management (temporary)
45
+ def get_pkce(server_url)
46
+ @pkce_data[server_url]
47
+ end
48
+
49
+ def set_pkce(server_url, pkce)
50
+ @pkce_data[server_url] = pkce
51
+ end
52
+
53
+ def delete_pkce(server_url)
54
+ @pkce_data.delete(server_url)
55
+ end
56
+
57
+ # State parameter management (temporary)
58
+ def get_state(server_url)
59
+ @state_data[server_url]
60
+ end
61
+
62
+ def set_state(server_url, state)
63
+ @state_data[server_url] = state
64
+ end
65
+
66
+ def delete_state(server_url)
67
+ @state_data.delete(server_url)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httpx"
4
+ require "uri"
5
+
6
+ module RubyLLM
7
+ module MCP
8
+ module Auth
9
+ # Core OAuth 2.1 provider implementing complete authorization flow
10
+ # Supports RFC 7636 (PKCE), RFC 7591 (Dynamic Registration),
11
+ # RFC 8414 (Server Metadata), RFC 8707 (Resource Indicators), RFC 9728 (Protected Resource Metadata)
12
+ #
13
+ # @note This class is not thread-safe. Each thread should use its own instance.
14
+ class OAuthProvider
15
+ attr_reader :server_url
16
+ attr_accessor :redirect_uri, :scope, :logger, :storage, :grant_type
17
+
18
+ # Normalize server URL for consistent comparison
19
+ # @param url [String] raw URL
20
+ # @return [String] normalized URL
21
+ def self.normalize_url(url)
22
+ uri = URI.parse(url)
23
+
24
+ uri.scheme = uri.scheme&.downcase
25
+ uri.host = uri.host&.downcase
26
+
27
+ if (uri.scheme == "http" && uri.port == 80) || (uri.scheme == "https" && uri.port == 443)
28
+ uri.port = nil
29
+ end
30
+
31
+ if uri.path.nil? || uri.path.empty? || uri.path == "/"
32
+ uri.path = ""
33
+ elsif uri.path.end_with?("/")
34
+ uri.path = uri.path.chomp("/")
35
+ end
36
+
37
+ uri.fragment = nil
38
+ uri.to_s
39
+ end
40
+
41
+ def initialize(server_url:, redirect_uri: "http://localhost:8080/callback", scope: nil, logger: nil, # rubocop:disable Metrics/ParameterLists
42
+ storage: nil, grant_type: :authorization_code)
43
+ self.server_url = server_url
44
+ self.redirect_uri = redirect_uri
45
+ self.scope = scope
46
+ self.logger = logger || MCP.logger
47
+ self.storage = storage || MemoryStorage.new
48
+ self.grant_type = grant_type.to_sym
49
+ validate_redirect_uri!(redirect_uri)
50
+
51
+ # Initialize HTTP client
52
+ @http_client = create_http_client
53
+
54
+ # Initialize service objects
55
+ @discoverer = Discoverer.new(@http_client, self.storage, self.logger)
56
+ @client_registrar = ClientRegistrar.new(@http_client, self.storage, self.logger, MCP.config)
57
+ @token_manager = TokenManager.new(@http_client, self.logger)
58
+ @session_manager = SessionManager.new(self.storage)
59
+
60
+ # Initialize flow orchestrators
61
+ @auth_code_flow = Flows::AuthorizationCodeFlow.new(
62
+ discoverer: @discoverer,
63
+ client_registrar: @client_registrar,
64
+ session_manager: @session_manager,
65
+ token_manager: @token_manager,
66
+ storage: self.storage,
67
+ logger: self.logger
68
+ )
69
+ @client_creds_flow = Flows::ClientCredentialsFlow.new(
70
+ discoverer: @discoverer,
71
+ client_registrar: @client_registrar,
72
+ token_manager: @token_manager,
73
+ storage: self.storage,
74
+ logger: self.logger
75
+ )
76
+ end
77
+
78
+ # Get current access token, refreshing if needed
79
+ # @return [Token, nil] valid access token or nil
80
+ def access_token
81
+ logger.debug("OAuth access_token: Looking up token for server_url='#{server_url}'")
82
+ token = storage.get_token(server_url)
83
+ logger.debug("OAuth access_token: Storage returned token=#{token ? 'present' : 'nil'}")
84
+
85
+ if token
86
+ logger.debug(" Token expires_at: #{token.expires_at}")
87
+ logger.debug(" Token expired?: #{token.expired?}")
88
+ logger.debug(" Token expires_soon?: #{token.expires_soon?}")
89
+ else
90
+ logger.warn("✗ No token found in storage for server_url='#{server_url}'")
91
+ logger.warn(" Check that authentication completed and stored the token")
92
+ return nil
93
+ end
94
+
95
+ # Return token if still valid
96
+ return token unless token.expired? || token.expires_soon?
97
+
98
+ # Try to refresh if we have a refresh token
99
+ logger.debug("Token expired or expiring soon, attempting refresh...")
100
+ refresh_token(token) if token.refresh_token
101
+ end
102
+
103
+ # Authenticate and return current access token
104
+ # This is a convenience method for consistency with BrowserOAuthProvider
105
+ # For standard OAuth flow, external authorization is required before calling this
106
+ # @return [Token] current valid access token
107
+ # @raise [Errors::TransportError] if not authenticated or token unavailable
108
+ def authenticate
109
+ token = access_token
110
+ unless token
111
+ raise Errors::TransportError.new(
112
+ message: "Not authenticated. Please complete OAuth authorization flow first. " \
113
+ "For standard OAuth, you must authorize externally and exchange the code."
114
+ )
115
+ end
116
+ token
117
+ end
118
+
119
+ # Start OAuth authorization flow (authorization code grant)
120
+ # @return [String] authorization URL for user to visit
121
+ def start_authorization_flow
122
+ @auth_code_flow.start(
123
+ server_url,
124
+ redirect_uri,
125
+ scope,
126
+ https_validator: method(:validate_https_endpoint)
127
+ )
128
+ end
129
+
130
+ # Perform client credentials flow (application authentication without user)
131
+ # @param scope [String] optional scope override
132
+ # @return [Token] access token
133
+ def client_credentials_flow(scope: nil)
134
+ @client_creds_flow.execute(server_url, redirect_uri, scope || self.scope)
135
+ end
136
+
137
+ # Complete OAuth authorization flow after callback
138
+ # @param code [String] authorization code from callback
139
+ # @param state [String] state parameter from callback
140
+ # @return [Token] access token
141
+ def complete_authorization_flow(code, state)
142
+ @auth_code_flow.complete(server_url, code, state)
143
+ end
144
+
145
+ # Apply authorization header to HTTP request
146
+ # @param request [HTTPX::Request] HTTP request object
147
+ def apply_authorization(request)
148
+ token = access_token
149
+ logger.debug("OAuth apply_authorization: token=#{token ? 'present' : 'nil'}")
150
+ return unless token
151
+
152
+ logger.debug("OAuth applying authorization header: #{token.to_header[0..20]}...")
153
+ request.headers["Authorization"] = token.to_header
154
+ end
155
+
156
+ private
157
+
158
+ # Create HTTP client for OAuth requests
159
+ # @return [HTTPX::Session] HTTP client
160
+ def create_http_client
161
+ headers = {
162
+ "Accept" => "application/json",
163
+ "User-Agent" => "RubyLLM-MCP/#{RubyLLM::MCP::VERSION}"
164
+ }
165
+ headers["MCP-Protocol-Version"] = RubyLLM::MCP.config.protocol_version
166
+
167
+ HTTPX.plugin(:follow_redirects).with(
168
+ timeout: { total: DEFAULT_OAUTH_TIMEOUT },
169
+ headers: headers
170
+ )
171
+ end
172
+
173
+ # Normalize and set server URL
174
+ # Ensures consistent URL format for storage keys
175
+ def server_url=(url)
176
+ @server_url = self.class.normalize_url(url)
177
+ end
178
+
179
+ # Validate redirect URI per OAuth 2.1 security requirements
180
+ # @param uri [String] redirect URI
181
+ # @raise [ArgumentError] if URI is invalid or not localhost/HTTPS
182
+ def validate_redirect_uri!(uri)
183
+ parsed = URI.parse(uri)
184
+ is_localhost = ["localhost", "127.0.0.1", "::1"].include?(parsed.host)
185
+ is_https = parsed.scheme == "https"
186
+
187
+ unless is_localhost || is_https
188
+ raise ArgumentError,
189
+ "Redirect URI must be localhost or HTTPS per OAuth 2.1 security requirements: #{uri}"
190
+ end
191
+ rescue URI::InvalidURIError => e
192
+ raise ArgumentError, "Invalid redirect URI: #{uri} - #{e.message}"
193
+ end
194
+
195
+ # Validate HTTPS usage for OAuth endpoint (warning only)
196
+ # @param url [String] endpoint URL
197
+ # @param endpoint_name [String] descriptive name for logging
198
+ def validate_https_endpoint(url, endpoint_name)
199
+ uri = URI.parse(url)
200
+ is_localhost = ["localhost", "127.0.0.1", "::1"].include?(uri.host)
201
+
202
+ if uri.scheme != "https" && !is_localhost
203
+ logger.warn("WARNING: #{endpoint_name} is not using HTTPS: #{url}")
204
+ logger.warn("OAuth endpoints SHOULD use HTTPS in production environments")
205
+ end
206
+ end
207
+
208
+ # Refresh access token using refresh token
209
+ # @param token [Token] current token with refresh_token
210
+ # @return [Token, nil] new token or nil if refresh failed
211
+ def refresh_token(token)
212
+ return nil unless token.refresh_token
213
+
214
+ server_metadata = @discoverer.discover(server_url)
215
+ client_info = storage.get_client_info(server_url)
216
+ return nil unless server_metadata && client_info
217
+
218
+ new_token = @token_manager.refresh_token(server_metadata, client_info, token, server_url)
219
+ storage.set_token(server_url, new_token) if new_token
220
+ logger.debug("Token refreshed successfully") if new_token
221
+ new_token
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ # Security utilities for OAuth implementation
7
+ module Security
8
+ module_function
9
+
10
+ # Constant-time string comparison to prevent timing attacks
11
+ # @param a [String] first string
12
+ # @param b [String] second string
13
+ # @return [Boolean] true if strings are equal
14
+ def secure_compare(first, second)
15
+ # Handle nil values
16
+ return false if first.nil? || second.nil?
17
+
18
+ # Use Rails/ActiveSupport's secure_compare if available (more battle-tested)
19
+ if defined?(ActiveSupport::SecurityUtils) && ActiveSupport::SecurityUtils.respond_to?(:secure_compare)
20
+ return ActiveSupport::SecurityUtils.secure_compare(first, second)
21
+ end
22
+
23
+ # Fallback to our own implementation
24
+ constant_time_compare?(first, second)
25
+ end
26
+
27
+ # Constant-time comparison implementation
28
+ # @param a [String] first string
29
+ # @param b [String] second string
30
+ # @return [Boolean] true if strings are equal
31
+ def constant_time_compare?(first, second)
32
+ return false unless first.bytesize == second.bytesize
33
+
34
+ l = first.unpack("C*")
35
+ r = 0
36
+ i = -1
37
+
38
+ second.each_byte { |v| r |= v ^ l[i += 1] }
39
+ r.zero?
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module RubyLLM
6
+ module MCP
7
+ module Auth
8
+ # Service for managing OAuth session state (PKCE and CSRF state)
9
+ # Handles creation, validation, and cleanup of temporary session data
10
+ class SessionManager
11
+ attr_reader :storage
12
+
13
+ def initialize(storage)
14
+ @storage = storage
15
+ end
16
+
17
+ # Create a new OAuth session with PKCE and CSRF state
18
+ # @param server_url [String] MCP server URL
19
+ # @return [Hash] session data with :pkce and :state
20
+ def create_session(server_url)
21
+ pkce = PKCE.new
22
+ state = SecureRandom.urlsafe_base64(CSRF_STATE_SIZE)
23
+
24
+ storage.set_pkce(server_url, pkce)
25
+ storage.set_state(server_url, state)
26
+
27
+ { pkce: pkce, state: state }
28
+ end
29
+
30
+ # Validate state parameter and retrieve session data
31
+ # @param server_url [String] MCP server URL
32
+ # @param state [String] state parameter from callback
33
+ # @return [Hash] session data with :pkce and :client_info
34
+ # @raise [ArgumentError] if state is invalid
35
+ def validate_and_retrieve_session(server_url, state)
36
+ stored_state = storage.get_state(server_url)
37
+ unless stored_state && Security.secure_compare(stored_state, state)
38
+ raise ArgumentError, "Invalid state parameter"
39
+ end
40
+
41
+ {
42
+ pkce: storage.get_pkce(server_url),
43
+ client_info: storage.get_client_info(server_url)
44
+ }
45
+ end
46
+
47
+ # Clean up temporary session data
48
+ # @param server_url [String] MCP server URL
49
+ def cleanup_session(server_url)
50
+ storage.delete_pkce(server_url)
51
+ storage.delete_state(server_url)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end