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,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "browser/http_server"
4
+ require_relative "browser/callback_handler"
5
+ require_relative "browser/pages"
6
+ require_relative "browser/opener"
7
+ require_relative "browser/callback_server"
8
+
9
+ module RubyLLM
10
+ module MCP
11
+ module Auth
12
+ # Browser-based OAuth authentication provider
13
+ # Provides complete OAuth 2.1 flow with automatic browser opening and local callback server
14
+ # Compatible API with OAuthProvider for seamless interchange
15
+ class BrowserOAuthProvider
16
+ attr_reader :oauth_provider, :callback_port, :callback_path, :logger
17
+ attr_accessor :server_url, :redirect_uri, :scope, :storage
18
+
19
+ # Expose custom pages for testing/inspection
20
+ def custom_success_page
21
+ @pages.instance_variable_get(:@custom_success_page)
22
+ end
23
+
24
+ def custom_error_page
25
+ @pages.instance_variable_get(:@custom_error_page)
26
+ end
27
+
28
+ # @param server_url [String] OAuth server URL (alternative to oauth_provider)
29
+ # @param oauth_provider [OAuthProvider] OAuth provider instance (alternative to server_url)
30
+ # @param callback_port [Integer] port for local callback server
31
+ # @param callback_path [String] path for callback URL
32
+ # @param logger [Logger] logger instance
33
+ # @param storage [Object] token storage instance
34
+ # @param redirect_uri [String] OAuth redirect URI
35
+ # @param scope [String] OAuth scopes
36
+ def initialize(server_url: nil, oauth_provider: nil, callback_port: 8080, callback_path: "/callback", # rubocop:disable Metrics/ParameterLists
37
+ logger: nil, storage: nil, redirect_uri: nil, scope: nil)
38
+ @logger = logger || MCP.logger
39
+ @callback_port = callback_port
40
+ @callback_path = callback_path
41
+
42
+ # Set redirect_uri before creating oauth_provider
43
+ redirect_uri ||= "http://localhost:#{callback_port}#{callback_path}"
44
+
45
+ # Either accept an existing oauth_provider or create one
46
+ if oauth_provider
47
+ @oauth_provider = oauth_provider
48
+ # Sync attributes from the provided oauth_provider
49
+ @server_url = oauth_provider.server_url
50
+ @redirect_uri = oauth_provider.redirect_uri
51
+ @scope = oauth_provider.scope
52
+ @storage = oauth_provider.storage
53
+ elsif server_url
54
+ @server_url = server_url
55
+ @redirect_uri = redirect_uri
56
+ @scope = scope
57
+ @storage = storage || MemoryStorage.new
58
+ # Create a new oauth_provider
59
+ @oauth_provider = OAuthProvider.new(
60
+ server_url: server_url,
61
+ redirect_uri: redirect_uri,
62
+ scope: scope,
63
+ logger: @logger,
64
+ storage: @storage
65
+ )
66
+ else
67
+ raise ArgumentError, "Either server_url or oauth_provider must be provided"
68
+ end
69
+
70
+ # Ensure OAuth provider redirect_uri matches our callback server
71
+ validate_and_sync_redirect_uri!
72
+
73
+ # Initialize browser helpers
74
+ @http_server = Browser::HttpServer.new(port: @callback_port, logger: @logger)
75
+ @callback_handler = Browser::CallbackHandler.new(callback_path: @callback_path, logger: @logger)
76
+ @pages = Browser::Pages.new(
77
+ custom_success_page: MCP.config.oauth.browser_success_page,
78
+ custom_error_page: MCP.config.oauth.browser_error_page
79
+ )
80
+ @opener = Browser::Opener.new(logger: @logger)
81
+ end
82
+
83
+ # Perform complete OAuth authentication flow with browser
84
+ # Compatible with OAuthProvider's authentication pattern
85
+ # @param timeout [Integer] seconds to wait for authorization
86
+ # @param auto_open_browser [Boolean] automatically open browser
87
+ # @return [Token] access token
88
+ def authenticate(timeout: 300, auto_open_browser: true)
89
+ # 1. Start authorization flow and get URL
90
+ auth_url = @oauth_provider.start_authorization_flow
91
+ @logger.debug("Authorization URL: #{auth_url}")
92
+
93
+ # 2. Create result container for thread coordination
94
+ result = { code: nil, state: nil, error: nil, completed: false }
95
+ mutex = Mutex.new
96
+ condition = ConditionVariable.new
97
+
98
+ # 3. Start local callback server
99
+ server = start_callback_server(result, mutex, condition)
100
+
101
+ begin
102
+ # 4. Open browser to authorization URL
103
+ if auto_open_browser
104
+ @opener.open_browser(auth_url)
105
+ @logger.info("\nOpening browser for authorization...")
106
+ @logger.info("If browser doesn't open automatically, visit this URL:")
107
+ else
108
+ @logger.info("\nPlease visit this URL to authorize:")
109
+ end
110
+ @logger.info(auth_url)
111
+ @logger.info("\nWaiting for authorization...")
112
+
113
+ # 5. Wait for callback with timeout
114
+ mutex.synchronize do
115
+ condition.wait(mutex, timeout) unless result[:completed]
116
+ end
117
+
118
+ unless result[:completed]
119
+ raise Errors::TimeoutError.new(message: "OAuth authorization timed out after #{timeout} seconds")
120
+ end
121
+
122
+ if result[:error]
123
+ raise Errors::TransportError.new(message: "OAuth authorization failed: #{result[:error]}")
124
+ end
125
+
126
+ # 6. Complete OAuth flow
127
+ @logger.debug("Completing OAuth authorization flow")
128
+ token = @oauth_provider.complete_authorization_flow(result[:code], result[:state])
129
+
130
+ @logger.info("\nAuthentication successful!")
131
+ token
132
+ ensure
133
+ # Always shutdown the server
134
+ server&.shutdown
135
+ end
136
+ end
137
+
138
+ # Get current access token (for compatibility with OAuthProvider)
139
+ # @return [Token, nil] valid access token or nil
140
+ def access_token
141
+ @oauth_provider.access_token
142
+ end
143
+
144
+ # Apply authorization header to HTTP request (for compatibility with OAuthProvider)
145
+ # @param request [HTTPX::Request] HTTP request object
146
+ def apply_authorization(request)
147
+ @oauth_provider.apply_authorization(request)
148
+ end
149
+
150
+ # Start authorization flow (for compatibility with OAuthProvider)
151
+ # @return [String] authorization URL
152
+ def start_authorization_flow
153
+ @oauth_provider.start_authorization_flow
154
+ end
155
+
156
+ # Complete authorization flow (for compatibility with OAuthProvider)
157
+ # @param code [String] authorization code
158
+ # @param state [String] state parameter
159
+ # @return [Token] access token
160
+ def complete_authorization_flow(code, state)
161
+ @oauth_provider.complete_authorization_flow(code, state)
162
+ end
163
+
164
+ private
165
+
166
+ # Validate and synchronize redirect_uri between this provider and oauth_provider
167
+ def validate_and_sync_redirect_uri!
168
+ expected_redirect_uri = "http://localhost:#{@callback_port}#{@callback_path}"
169
+
170
+ if @oauth_provider.redirect_uri != expected_redirect_uri
171
+ @logger.warn("OAuth provider redirect_uri (#{@oauth_provider.redirect_uri}) " \
172
+ "doesn't match callback server (#{expected_redirect_uri}). " \
173
+ "Updating redirect_uri.")
174
+ @oauth_provider.redirect_uri = expected_redirect_uri
175
+ @redirect_uri = expected_redirect_uri
176
+ end
177
+ end
178
+
179
+ # Start local HTTP callback server
180
+ # @param result [Hash] result container for callback data
181
+ # @param mutex [Mutex] synchronization mutex
182
+ # @param condition [ConditionVariable] wait condition
183
+ # @return [Browser::CallbackServer] server wrapper
184
+ def start_callback_server(result, mutex, condition)
185
+ server = @http_server.start_server
186
+ @logger.debug("Started callback server on http://127.0.0.1:#{@callback_port}#{@callback_path}")
187
+
188
+ running = true
189
+
190
+ # Start server in background thread
191
+ thread = Thread.new do
192
+ while running
193
+ begin
194
+ # Use wait_readable with timeout to allow checking running flag
195
+ next unless server.wait_readable(0.5)
196
+
197
+ client = server.accept
198
+ handle_http_request(client, result, mutex, condition)
199
+ rescue IOError, Errno::EBADF
200
+ # Server was closed, exit loop
201
+ break
202
+ rescue StandardError => e
203
+ @logger.error("Error handling callback request: #{e.message}")
204
+ end
205
+ end
206
+ end
207
+
208
+ # Return wrapper with shutdown method
209
+ Browser::CallbackServer.new(server, thread, -> { running = false })
210
+ end
211
+
212
+ # Handle incoming HTTP request on callback server
213
+ # @param client [TCPSocket] client socket
214
+ # @param result [Hash] result container
215
+ # @param mutex [Mutex] synchronization mutex
216
+ # @param condition [ConditionVariable] wait condition
217
+ def handle_http_request(client, result, mutex, condition)
218
+ @http_server.configure_client_socket(client)
219
+
220
+ request_line = @http_server.read_request_line(client)
221
+ return unless request_line
222
+
223
+ method_name, path = @http_server.extract_request_parts(request_line)
224
+ return unless method_name && path
225
+
226
+ @logger.debug("Received #{method_name} request: #{path}")
227
+ @http_server.read_http_headers(client)
228
+
229
+ # Validate callback path
230
+ unless @callback_handler.valid_callback_path?(path)
231
+ @http_server.send_http_response(client, 404, "text/plain", "Not Found")
232
+ return
233
+ end
234
+
235
+ # Parse and extract OAuth parameters
236
+ params = @callback_handler.parse_callback_params(path, @http_server)
237
+ oauth_params = @callback_handler.extract_oauth_params(params)
238
+
239
+ # Update result with OAuth parameters
240
+ @callback_handler.update_result_with_oauth_params(oauth_params, result, mutex, condition)
241
+
242
+ # Send response
243
+ if result[:error]
244
+ @http_server.send_http_response(client, 400, "text/html", @pages.error_page(result[:error]))
245
+ else
246
+ @http_server.send_http_response(client, 200, "text/html", @pages.success_page)
247
+ end
248
+ ensure
249
+ client&.close
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ # Service for registering OAuth clients
7
+ # Implements RFC 7591 (Dynamic Client Registration)
8
+ class ClientRegistrar
9
+ attr_reader :http_client, :storage, :logger, :config
10
+
11
+ def initialize(http_client, storage, logger, config)
12
+ @http_client = http_client
13
+ @storage = storage
14
+ @logger = logger
15
+ @config = config
16
+ end
17
+
18
+ # Get cached client info or register new client
19
+ # @param server_url [String] MCP server URL
20
+ # @param server_metadata [ServerMetadata] server metadata
21
+ # @param grant_type [Symbol] :authorization_code or :client_credentials
22
+ # @param redirect_uri [String] redirect URI for authorization code flow
23
+ # @param scope [String, nil] requested scope
24
+ # @return [ClientInfo] client information
25
+ def get_or_register(server_url, server_metadata, grant_type, redirect_uri, scope)
26
+ # Check cache first
27
+ client_info = storage.get_client_info(server_url)
28
+ return client_info if client_info && !client_info.client_secret_expired?
29
+
30
+ # Register new client if no cached info or secret expired
31
+ if server_metadata.supports_registration?
32
+ register(server_url, server_metadata, grant_type, redirect_uri, scope)
33
+ else
34
+ raise Errors::TransportError.new(
35
+ message: "OAuth server does not support dynamic client registration"
36
+ )
37
+ end
38
+ end
39
+
40
+ # Register OAuth client dynamically (RFC 7591)
41
+ # @param server_url [String] MCP server URL
42
+ # @param server_metadata [ServerMetadata] server metadata
43
+ # @param grant_type [Symbol] :authorization_code or :client_credentials
44
+ # @param redirect_uri [String] redirect URI for authorization code flow
45
+ # @param scope [String, nil] requested scope
46
+ # @return [ClientInfo] registered client info
47
+ def register(server_url, server_metadata, grant_type, redirect_uri, scope)
48
+ logger.debug("Registering OAuth client at: #{server_metadata.registration_endpoint}")
49
+
50
+ metadata = build_client_metadata(grant_type, redirect_uri, scope)
51
+ response = post_registration(server_metadata, metadata)
52
+ data = HttpResponseHandler.handle_response(response, context: "Client registration",
53
+ expected_status: [200, 201])
54
+
55
+ registered_metadata = parse_registered_metadata(data, redirect_uri)
56
+ warn_redirect_uri_mismatch(registered_metadata, redirect_uri)
57
+
58
+ client_info = create_client_info(data, registered_metadata)
59
+ storage.set_client_info(server_url, client_info)
60
+ logger.debug("Client registered successfully: #{client_info.client_id}")
61
+ client_info
62
+ end
63
+
64
+ private
65
+
66
+ # Build client metadata for registration request
67
+ # @param grant_type [Symbol] :authorization_code or :client_credentials
68
+ # @param redirect_uri [String] redirect URI
69
+ # @param scope [String, nil] requested scope
70
+ # @return [ClientMetadata] client metadata
71
+ def build_client_metadata(grant_type, redirect_uri, scope)
72
+ strategy = grant_strategy_for(grant_type)
73
+
74
+ metadata = {
75
+ redirect_uris: [redirect_uri],
76
+ token_endpoint_auth_method: strategy.auth_method,
77
+ grant_types: strategy.grant_types_list,
78
+ response_types: strategy.response_types_list,
79
+ scope: scope,
80
+ client_name: config.oauth.client_name,
81
+ client_uri: config.oauth.client_uri,
82
+ logo_uri: config.oauth.logo_uri,
83
+ contacts: config.oauth.contacts,
84
+ tos_uri: config.oauth.tos_uri,
85
+ policy_uri: config.oauth.policy_uri,
86
+ jwks_uri: config.oauth.jwks_uri,
87
+ jwks: config.oauth.jwks,
88
+ software_id: config.oauth.software_id,
89
+ software_version: config.oauth.software_version
90
+ }.compact
91
+
92
+ ClientMetadata.new(**metadata)
93
+ end
94
+
95
+ # Get grant strategy for grant type
96
+ # @param grant_type [Symbol] :authorization_code or :client_credentials
97
+ # @return [GrantStrategies::Base] grant strategy
98
+ def grant_strategy_for(grant_type)
99
+ case grant_type
100
+ when :client_credentials
101
+ GrantStrategies::ClientCredentials.new
102
+ else
103
+ GrantStrategies::AuthorizationCode.new
104
+ end
105
+ end
106
+
107
+ # Post client registration request
108
+ # @param server_metadata [ServerMetadata] server metadata
109
+ # @param metadata [ClientMetadata] client metadata
110
+ # @return [HTTPX::Response] HTTP response
111
+ def post_registration(server_metadata, metadata)
112
+ http_client.post(
113
+ server_metadata.registration_endpoint,
114
+ headers: { "Content-Type" => "application/json" },
115
+ json: metadata.to_h
116
+ )
117
+ end
118
+
119
+ # Parse registered client metadata from response
120
+ # @param data [Hash] registration response data
121
+ # @param redirect_uri [String] requested redirect URI
122
+ # @return [ClientMetadata] registered metadata
123
+ def parse_registered_metadata(data, redirect_uri)
124
+ ClientMetadata.new(
125
+ redirect_uris: data["redirect_uris"] || [redirect_uri],
126
+ token_endpoint_auth_method: data["token_endpoint_auth_method"] || "none",
127
+ grant_types: data["grant_types"] || %w[authorization_code refresh_token],
128
+ response_types: data["response_types"] || ["code"],
129
+ scope: data["scope"],
130
+ client_name: data["client_name"],
131
+ client_uri: data["client_uri"],
132
+ logo_uri: data["logo_uri"],
133
+ contacts: data["contacts"],
134
+ tos_uri: data["tos_uri"],
135
+ policy_uri: data["policy_uri"],
136
+ jwks_uri: data["jwks_uri"],
137
+ jwks: data["jwks"],
138
+ software_id: data["software_id"],
139
+ software_version: data["software_version"]
140
+ )
141
+ end
142
+
143
+ # Warn if server changed redirect URI
144
+ # @param registered_metadata [ClientMetadata] registered metadata
145
+ # @param redirect_uri [String] requested redirect URI
146
+ def warn_redirect_uri_mismatch(registered_metadata, redirect_uri)
147
+ return if registered_metadata.redirect_uris.first == redirect_uri
148
+
149
+ logger.warn("OAuth server changed redirect_uri:")
150
+ logger.warn(" Requested: #{redirect_uri}")
151
+ logger.warn(" Registered: #{registered_metadata.redirect_uris.first}")
152
+ end
153
+
154
+ # Create client info from registration response
155
+ # @param data [Hash] registration response data
156
+ # @param registered_metadata [ClientMetadata] registered metadata
157
+ # @return [ClientInfo] client info
158
+ def create_client_info(data, registered_metadata)
159
+ ClientInfo.new(
160
+ client_id: data["client_id"],
161
+ client_secret: data["client_secret"],
162
+ client_id_issued_at: data["client_id_issued_at"],
163
+ client_secret_expires_at: data["client_secret_expires_at"],
164
+ metadata: registered_metadata
165
+ )
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ # Service for discovering OAuth authorization servers
7
+ # Implements RFC 8414 (Server Metadata) and RFC 9728 (Protected Resource Metadata)
8
+ class Discoverer
9
+ attr_reader :http_client, :storage, :logger
10
+
11
+ def initialize(http_client, storage, logger)
12
+ @http_client = http_client
13
+ @storage = storage
14
+ @logger = logger
15
+ end
16
+
17
+ # Discover OAuth authorization server
18
+ # Tries two patterns: server as own auth server, or delegated auth server
19
+ # @param server_url [String] MCP server URL
20
+ # @return [ServerMetadata, nil] server metadata or nil
21
+ def discover(server_url)
22
+ logger.debug("Discovering OAuth authorization server for #{server_url}")
23
+
24
+ # Check cache first
25
+ cached = storage.get_server_metadata(server_url)
26
+ return cached if cached
27
+
28
+ server_metadata = try_authorization_server_discovery(server_url) ||
29
+ try_protected_resource_discovery(server_url) ||
30
+ create_default_metadata(server_url)
31
+
32
+ # Cache and return
33
+ storage.set_server_metadata(server_url, server_metadata) if server_metadata
34
+ server_metadata
35
+ end
36
+
37
+ private
38
+
39
+ # Try oauth-authorization-server discovery (server is own auth server)
40
+ # @param server_url [String] MCP server URL
41
+ # @return [ServerMetadata, nil] server metadata or nil
42
+ def try_authorization_server_discovery(server_url)
43
+ discovery_url = UrlBuilder.build_discovery_url(server_url, :authorization_server)
44
+ logger.debug("Trying discovery URL: #{discovery_url}")
45
+ fetch_server_metadata(discovery_url)
46
+ rescue StandardError => e
47
+ logger.debug("oauth-authorization-server discovery failed: #{e.message}")
48
+ nil
49
+ end
50
+
51
+ # Try oauth-protected-resource discovery (delegation pattern)
52
+ # @param server_url [String] MCP server URL
53
+ # @return [ServerMetadata, nil] server metadata or nil
54
+ def try_protected_resource_discovery(server_url)
55
+ discovery_url = UrlBuilder.build_discovery_url(server_url, :protected_resource)
56
+ logger.debug("Trying protected resource discovery: #{discovery_url}")
57
+ resource_metadata = fetch_resource_metadata(discovery_url)
58
+ auth_server_url = resource_metadata.authorization_servers.first
59
+
60
+ if auth_server_url
61
+ logger.debug("Found delegated auth server: #{auth_server_url}")
62
+ fetch_server_metadata("#{auth_server_url}/.well-known/oauth-authorization-server")
63
+ end
64
+ rescue StandardError => e
65
+ logger.debug("oauth-protected-resource discovery failed: #{e.message}")
66
+ nil
67
+ end
68
+
69
+ # Create default server metadata when discovery fails
70
+ # @param server_url [String] MCP server URL
71
+ # @return [ServerMetadata] server metadata with default endpoints
72
+ def create_default_metadata(server_url)
73
+ base_url = UrlBuilder.get_authorization_base_url(server_url)
74
+ logger.warn("OAuth discovery failed, falling back to default endpoints")
75
+ logger.info("Using default OAuth endpoints for #{base_url}")
76
+
77
+ ServerMetadata.new(
78
+ issuer: base_url,
79
+ authorization_endpoint: "#{base_url}/authorize",
80
+ token_endpoint: "#{base_url}/token",
81
+ options: { registration_endpoint: "#{base_url}/register" }
82
+ )
83
+ end
84
+
85
+ # Fetch OAuth server metadata
86
+ # @param url [String] discovery URL
87
+ # @return [ServerMetadata] server metadata
88
+ def fetch_server_metadata(url)
89
+ logger.debug("Fetching server metadata from #{url}")
90
+ response = http_client.get(url)
91
+
92
+ data = HttpResponseHandler.handle_response(response, context: "Server metadata fetch")
93
+
94
+ ServerMetadata.new(
95
+ issuer: data["issuer"],
96
+ authorization_endpoint: data["authorization_endpoint"],
97
+ token_endpoint: data["token_endpoint"],
98
+ options: {
99
+ registration_endpoint: data["registration_endpoint"],
100
+ scopes_supported: data["scopes_supported"],
101
+ response_types_supported: data["response_types_supported"],
102
+ grant_types_supported: data["grant_types_supported"]
103
+ }
104
+ )
105
+ end
106
+
107
+ # Fetch OAuth protected resource metadata
108
+ # @param url [String] discovery URL
109
+ # @return [ResourceMetadata] resource metadata
110
+ def fetch_resource_metadata(url)
111
+ logger.debug("Fetching resource metadata from #{url}")
112
+ response = http_client.get(url)
113
+
114
+ data = HttpResponseHandler.handle_response(response, context: "Resource metadata fetch")
115
+
116
+ ResourceMetadata.new(
117
+ resource: data["resource"],
118
+ authorization_servers: data["authorization_servers"]
119
+ )
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module Flows
7
+ # Orchestrates OAuth 2.1 Authorization Code flow with PKCE
8
+ # Coordinates session management, discovery, registration, and token exchange
9
+ class AuthorizationCodeFlow
10
+ attr_reader :discoverer, :client_registrar, :session_manager, :token_manager, :storage, :logger
11
+
12
+ def initialize(discoverer:, client_registrar:, session_manager:, token_manager:, storage:, logger:) # rubocop:disable Metrics/ParameterLists
13
+ @discoverer = discoverer
14
+ @client_registrar = client_registrar
15
+ @session_manager = session_manager
16
+ @token_manager = token_manager
17
+ @storage = storage
18
+ @logger = logger
19
+ end
20
+
21
+ # Start OAuth authorization flow
22
+ # @param server_url [String] MCP server URL
23
+ # @param redirect_uri [String] redirect URI for callback
24
+ # @param scope [String, nil] requested scope
25
+ # @param https_validator [Proc] callback to validate HTTPS usage
26
+ # @return [String] authorization URL for user to visit
27
+ def start(server_url, redirect_uri, scope, https_validator: nil)
28
+ logger.debug("Starting OAuth authorization flow for #{server_url}")
29
+
30
+ # 1. Discover authorization server
31
+ server_metadata = discoverer.discover(server_url)
32
+ raise Errors::TransportError.new(message: "OAuth server discovery failed") unless server_metadata
33
+
34
+ # 2. Register client (or get cached client)
35
+ client_info = client_registrar.get_or_register(
36
+ server_url,
37
+ server_metadata,
38
+ :authorization_code,
39
+ redirect_uri,
40
+ scope
41
+ )
42
+
43
+ # 3. Create session with PKCE and CSRF state
44
+ session = session_manager.create_session(server_url)
45
+
46
+ # 4. Validate HTTPS usage (optional warning)
47
+ https_validator&.call(server_metadata.authorization_endpoint, "Authorization endpoint")
48
+
49
+ # 5. Build and return authorization URL
50
+ auth_url = UrlBuilder.build_authorization_url(
51
+ server_metadata.authorization_endpoint,
52
+ client_info.client_id,
53
+ client_info.metadata.redirect_uris.first,
54
+ scope,
55
+ session[:state],
56
+ session[:pkce],
57
+ server_url
58
+ )
59
+
60
+ logger.debug("Authorization URL: #{auth_url}")
61
+ auth_url
62
+ end
63
+
64
+ # Complete OAuth authorization flow after callback
65
+ # @param server_url [String] MCP server URL
66
+ # @param code [String] authorization code from callback
67
+ # @param state [String] state parameter from callback
68
+ # @return [Token] access token
69
+ def complete(server_url, code, state)
70
+ logger.debug("Completing OAuth authorization flow")
71
+
72
+ # 1. Validate state and retrieve session data
73
+ session_data = session_manager.validate_and_retrieve_session(server_url, state)
74
+
75
+ pkce = session_data[:pkce]
76
+ client_info = session_data[:client_info]
77
+ server_metadata = discoverer.discover(server_url)
78
+
79
+ unless pkce && client_info
80
+ raise Errors::TransportError.new(message: "Missing PKCE or client info")
81
+ end
82
+
83
+ # 2. Exchange authorization code for tokens
84
+ token = token_manager.exchange_authorization_code(
85
+ server_metadata,
86
+ client_info,
87
+ code,
88
+ pkce,
89
+ server_url
90
+ )
91
+
92
+ # 3. Store token
93
+ storage.set_token(server_url, token)
94
+
95
+ # 4. Clean up temporary session data
96
+ session_manager.cleanup_session(server_url)
97
+
98
+ logger.info("OAuth authorization completed successfully")
99
+ token
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end