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.
- checksums.yaml +4 -4
- data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
- data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
- data/lib/ruby_llm/mcp/auth/browser/http_server.rb +115 -0
- data/lib/ruby_llm/mcp/auth/browser/opener.rb +41 -0
- data/lib/ruby_llm/mcp/auth/browser/pages.rb +539 -0
- data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +254 -0
- data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
- data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
- data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
- data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
- data/lib/ruby_llm/mcp/auth/http_response_handler.rb +65 -0
- data/lib/ruby_llm/mcp/auth/memory_storage.rb +72 -0
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +226 -0
- data/lib/ruby_llm/mcp/auth/security.rb +44 -0
- data/lib/ruby_llm/mcp/auth/session_manager.rb +56 -0
- data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +78 -0
- data/lib/ruby_llm/mcp/auth.rb +359 -0
- data/lib/ruby_llm/mcp/client.rb +49 -0
- data/lib/ruby_llm/mcp/configuration.rb +39 -13
- data/lib/ruby_llm/mcp/coordinator.rb +11 -0
- data/lib/ruby_llm/mcp/errors.rb +11 -0
- data/lib/ruby_llm/mcp/railtie.rb +2 -10
- data/lib/ruby_llm/mcp/tool.rb +1 -1
- data/lib/ruby_llm/mcp/transport.rb +94 -1
- data/lib/ruby_llm/mcp/transports/sse.rb +116 -22
- data/lib/ruby_llm/mcp/transports/stdio.rb +4 -3
- data/lib/ruby_llm/mcp/transports/streamable_http.rb +81 -79
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +10 -4
- metadata +40 -5
- /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +0 -0
- /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
|