ruby-mcp-client 0.6.2 → 0.7.1

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,514 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+ require 'uri'
6
+ require_relative '../auth'
7
+
8
+ module MCPClient
9
+ module Auth
10
+ # OAuth 2.1 provider for MCP client authentication
11
+ # Handles the complete OAuth flow including server discovery, client registration,
12
+ # authorization, token exchange, and refresh
13
+ class OAuthProvider
14
+ # @!attribute [rw] redirect_uri
15
+ # @return [String] OAuth redirect URI
16
+ # @!attribute [rw] scope
17
+ # @return [String, nil] OAuth scope
18
+ # @!attribute [rw] logger
19
+ # @return [Logger] Logger instance
20
+ # @!attribute [rw] storage
21
+ # @return [Object] Storage backend for tokens and client info
22
+ # @!attribute [r] server_url
23
+ # @return [String] The MCP server URL (normalized)
24
+ attr_accessor :redirect_uri, :scope, :logger, :storage
25
+ attr_reader :server_url
26
+
27
+ # Initialize OAuth provider
28
+ # @param server_url [String] The MCP server URL (used as OAuth resource parameter)
29
+ # @param redirect_uri [String] OAuth redirect URI (default: http://localhost:8080/callback)
30
+ # @param scope [String, nil] OAuth scope
31
+ # @param logger [Logger, nil] Optional logger
32
+ # @param storage [Object, nil] Storage backend for tokens and client info
33
+ def initialize(server_url:, redirect_uri: 'http://localhost:8080/callback', scope: nil, logger: nil, storage: nil)
34
+ self.server_url = server_url
35
+ self.redirect_uri = redirect_uri
36
+ self.scope = scope
37
+ self.logger = logger || Logger.new($stdout, level: Logger::WARN)
38
+ self.storage = storage || MemoryStorage.new
39
+ @http_client = create_http_client
40
+ end
41
+
42
+ # @param url [String] Server URL to normalize
43
+ def server_url=(url)
44
+ @server_url = normalize_server_url(url)
45
+ end
46
+
47
+ # Get current access token (refresh if needed)
48
+ # @return [Token, nil] Current valid access token or nil
49
+ def access_token
50
+ token = storage.get_token(server_url)
51
+ logger.debug("OAuth access_token: retrieved token=#{token ? 'present' : 'nil'} for #{server_url}")
52
+ return nil unless token
53
+
54
+ # Return token if still valid
55
+ return token unless token.expired? || token.expires_soon?
56
+
57
+ # Try to refresh if we have a refresh token
58
+ refresh_token(token) if token.refresh_token
59
+ end
60
+
61
+ # Start OAuth authorization flow
62
+ # @return [String] Authorization URL to redirect user to
63
+ # @raise [MCPClient::Errors::ConnectionError] if server discovery fails
64
+ def start_authorization_flow
65
+ # Discover authorization server
66
+ server_metadata = discover_authorization_server
67
+
68
+ # Register client if needed
69
+ client_info = get_or_register_client(server_metadata)
70
+
71
+ # Generate PKCE parameters
72
+ pkce = PKCE.new
73
+ storage.set_pkce(server_url, pkce)
74
+
75
+ # Generate state parameter
76
+ state = SecureRandom.urlsafe_base64(32)
77
+ storage.set_state(server_url, state)
78
+
79
+ # Build authorization URL
80
+ build_authorization_url(server_metadata, client_info, pkce, state)
81
+ end
82
+
83
+ # Complete OAuth authorization flow with authorization code
84
+ # @param code [String] Authorization code from callback
85
+ # @param state [String] State parameter from callback
86
+ # @return [Token] Access token
87
+ # @raise [MCPClient::Errors::ConnectionError] if token exchange fails
88
+ # @raise [ArgumentError] if state parameter doesn't match
89
+ def complete_authorization_flow(code, state)
90
+ # Verify state parameter
91
+ stored_state = storage.get_state(server_url)
92
+ raise ArgumentError, 'Invalid state parameter' unless stored_state == state
93
+
94
+ # Get stored PKCE and client info
95
+ pkce = storage.get_pkce(server_url)
96
+ client_info = storage.get_client_info(server_url)
97
+ server_metadata = discover_authorization_server
98
+
99
+ raise MCPClient::Errors::ConnectionError, 'Missing PKCE or client info' unless pkce && client_info
100
+
101
+ # Exchange authorization code for tokens
102
+ token = exchange_authorization_code(server_metadata, client_info, code, pkce)
103
+
104
+ # Store token
105
+ storage.set_token(server_url, token)
106
+
107
+ # Clean up temporary data
108
+ storage.delete_pkce(server_url)
109
+ storage.delete_state(server_url)
110
+
111
+ token
112
+ end
113
+
114
+ # Apply OAuth authorization to HTTP request
115
+ # @param request [Faraday::Request] HTTP request to authorize
116
+ # @return [void]
117
+ def apply_authorization(request)
118
+ token = access_token
119
+ logger.debug("OAuth apply_authorization: token=#{token ? 'present' : 'nil'}")
120
+ return unless token
121
+
122
+ logger.debug("OAuth applying authorization header: #{token.to_header[0..20]}...")
123
+ request.headers['Authorization'] = token.to_header
124
+ end
125
+
126
+ # Handle 401 Unauthorized response (for server discovery)
127
+ # @param response [Faraday::Response] HTTP response
128
+ # @return [ResourceMetadata, nil] Resource metadata if found
129
+ def handle_unauthorized_response(response)
130
+ www_authenticate = response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
131
+ return nil unless www_authenticate
132
+
133
+ # Parse WWW-Authenticate header to extract resource metadata URL
134
+ # Format: Bearer resource="https://example.com/.well-known/oauth-protected-resource"
135
+ if (match = www_authenticate.match(/resource="([^"]+)"/))
136
+ resource_metadata_url = match[1]
137
+ fetch_resource_metadata(resource_metadata_url)
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ # Normalize server URL to canonical form
144
+ # @param url [String] Server URL
145
+ # @return [String] Normalized URL
146
+ def normalize_server_url(url)
147
+ uri = URI.parse(url)
148
+
149
+ # Use lowercase scheme and host
150
+ uri.scheme = uri.scheme.downcase
151
+ uri.host = uri.host.downcase
152
+
153
+ # Remove default ports
154
+ uri.port = nil if (uri.scheme == 'http' && uri.port == 80) || (uri.scheme == 'https' && uri.port == 443)
155
+
156
+ # Remove trailing slash for empty path or just "/"
157
+ if uri.path.nil? || uri.path.empty? || uri.path == '/'
158
+ uri.path = ''
159
+ elsif uri.path.end_with?('/')
160
+ uri.path = uri.path.chomp('/')
161
+ end
162
+
163
+ # Remove fragment
164
+ uri.fragment = nil
165
+
166
+ uri.to_s
167
+ end
168
+
169
+ # Create HTTP client for OAuth requests
170
+ # @return [Faraday::Connection] HTTP client
171
+ def create_http_client
172
+ Faraday.new do |f|
173
+ f.request :retry, max: 3, interval: 1, backoff_factor: 2
174
+ f.options.timeout = 30
175
+ f.adapter Faraday.default_adapter
176
+ end
177
+ end
178
+
179
+ # Build OAuth discovery URL from server URL
180
+ # Uses only the origin (scheme + host + port) for discovery
181
+ # @param server_url [String] Full MCP server URL
182
+ # @return [String] Discovery URL
183
+ def build_discovery_url(server_url)
184
+ uri = URI.parse(server_url)
185
+
186
+ # Build origin URL (scheme + host + port)
187
+ origin = "#{uri.scheme}://#{uri.host}"
188
+ origin += ":#{uri.port}" if uri.port && !default_port?(uri)
189
+
190
+ "#{origin}/.well-known/oauth-protected-resource"
191
+ end
192
+
193
+ # Check if URI uses default port for its scheme
194
+ # @param uri [URI] Parsed URI
195
+ # @return [Boolean] true if using default port
196
+ def default_port?(uri)
197
+ (uri.scheme == 'http' && uri.port == 80) ||
198
+ (uri.scheme == 'https' && uri.port == 443)
199
+ end
200
+
201
+ # Discover authorization server metadata
202
+ # @return [ServerMetadata] Authorization server metadata
203
+ # @raise [MCPClient::Errors::ConnectionError] if discovery fails
204
+ def discover_authorization_server
205
+ # Try to get from storage first
206
+ if (cached = storage.get_server_metadata(server_url))
207
+ return cached
208
+ end
209
+
210
+ # Build discovery URL using the origin (scheme + host + port) only
211
+ discovery_url = build_discovery_url(server_url)
212
+
213
+ # Fetch resource metadata to find authorization server
214
+ resource_metadata = fetch_resource_metadata(discovery_url)
215
+
216
+ # Get first authorization server
217
+ auth_server_url = resource_metadata.authorization_servers.first
218
+ raise MCPClient::Errors::ConnectionError, 'No authorization servers found' unless auth_server_url
219
+
220
+ # Fetch authorization server metadata
221
+ server_metadata = fetch_server_metadata("#{auth_server_url}/.well-known/oauth-authorization-server")
222
+
223
+ # Cache the metadata
224
+ storage.set_server_metadata(server_url, server_metadata)
225
+
226
+ server_metadata
227
+ end
228
+
229
+ # Fetch resource metadata from URL
230
+ # @param url [String] Resource metadata URL
231
+ # @return [ResourceMetadata] Resource metadata
232
+ # @raise [MCPClient::Errors::ConnectionError] if fetch fails
233
+ def fetch_resource_metadata(url)
234
+ logger.debug("Fetching resource metadata from: #{url}")
235
+
236
+ response = @http_client.get(url) do |req|
237
+ req.headers['Accept'] = 'application/json'
238
+ end
239
+
240
+ unless response.success?
241
+ raise MCPClient::Errors::ConnectionError, "Failed to fetch resource metadata: HTTP #{response.status}"
242
+ end
243
+
244
+ data = JSON.parse(response.body)
245
+ ResourceMetadata.from_h(data)
246
+ rescue JSON::ParserError => e
247
+ raise MCPClient::Errors::ConnectionError, "Invalid resource metadata JSON: #{e.message}"
248
+ rescue Faraday::Error => e
249
+ raise MCPClient::Errors::ConnectionError, "Network error fetching resource metadata: #{e.message}"
250
+ end
251
+
252
+ # Fetch authorization server metadata from URL
253
+ # @param url [String] Server metadata URL
254
+ # @return [ServerMetadata] Server metadata
255
+ # @raise [MCPClient::Errors::ConnectionError] if fetch fails
256
+ def fetch_server_metadata(url)
257
+ logger.debug("Fetching server metadata from: #{url}")
258
+
259
+ response = @http_client.get(url) do |req|
260
+ req.headers['Accept'] = 'application/json'
261
+ end
262
+
263
+ unless response.success?
264
+ raise MCPClient::Errors::ConnectionError, "Failed to fetch server metadata: HTTP #{response.status}"
265
+ end
266
+
267
+ data = JSON.parse(response.body)
268
+ ServerMetadata.from_h(data)
269
+ rescue JSON::ParserError => e
270
+ raise MCPClient::Errors::ConnectionError, "Invalid server metadata JSON: #{e.message}"
271
+ rescue Faraday::Error => e
272
+ raise MCPClient::Errors::ConnectionError, "Network error fetching server metadata: #{e.message}"
273
+ end
274
+
275
+ # Get or register OAuth client
276
+ # @param server_metadata [ServerMetadata] Authorization server metadata
277
+ # @return [ClientInfo] Client information
278
+ # @raise [MCPClient::Errors::ConnectionError] if registration fails
279
+ def get_or_register_client(server_metadata)
280
+ # Try to get existing client info from storage
281
+ if (client_info = storage.get_client_info(server_url)) && !client_info.client_secret_expired?
282
+ return client_info
283
+ end
284
+
285
+ # Register new client if server supports it
286
+ if server_metadata.supports_registration?
287
+ register_client(server_metadata)
288
+ else
289
+ raise MCPClient::Errors::ConnectionError,
290
+ 'Dynamic client registration not supported and no client credentials found'
291
+ end
292
+ end
293
+
294
+ # Register OAuth client dynamically
295
+ # @param server_metadata [ServerMetadata] Authorization server metadata
296
+ # @return [ClientInfo] Registered client information
297
+ # @raise [MCPClient::Errors::ConnectionError] if registration fails
298
+ def register_client(server_metadata)
299
+ logger.debug("Registering OAuth client at: #{server_metadata.registration_endpoint}")
300
+
301
+ metadata = ClientMetadata.new(
302
+ redirect_uris: [redirect_uri],
303
+ token_endpoint_auth_method: 'none', # Public client
304
+ grant_types: %w[authorization_code refresh_token],
305
+ response_types: ['code'],
306
+ scope: scope
307
+ )
308
+
309
+ response = @http_client.post(server_metadata.registration_endpoint) do |req|
310
+ req.headers['Content-Type'] = 'application/json'
311
+ req.headers['Accept'] = 'application/json'
312
+ req.body = metadata.to_h.to_json
313
+ end
314
+
315
+ unless response.success?
316
+ raise MCPClient::Errors::ConnectionError, "Client registration failed: HTTP #{response.status}"
317
+ end
318
+
319
+ data = JSON.parse(response.body)
320
+ client_info = ClientInfo.new(
321
+ client_id: data['client_id'],
322
+ client_secret: data['client_secret'],
323
+ client_id_issued_at: data['client_id_issued_at'],
324
+ client_secret_expires_at: data['client_secret_expires_at'],
325
+ metadata: metadata
326
+ )
327
+
328
+ # Store client info
329
+ storage.set_client_info(server_url, client_info)
330
+
331
+ client_info
332
+ rescue JSON::ParserError => e
333
+ raise MCPClient::Errors::ConnectionError, "Invalid client registration response: #{e.message}"
334
+ rescue Faraday::Error => e
335
+ raise MCPClient::Errors::ConnectionError, "Network error during client registration: #{e.message}"
336
+ end
337
+
338
+ # Build authorization URL
339
+ # @param server_metadata [ServerMetadata] Server metadata
340
+ # @param client_info [ClientInfo] Client information
341
+ # @param pkce [PKCE] PKCE parameters
342
+ # @param state [String] State parameter
343
+ # @return [String] Authorization URL
344
+ def build_authorization_url(server_metadata, client_info, pkce, state)
345
+ params = {
346
+ response_type: 'code',
347
+ client_id: client_info.client_id,
348
+ redirect_uri: redirect_uri,
349
+ scope: scope,
350
+ state: state,
351
+ code_challenge: pkce.code_challenge,
352
+ code_challenge_method: pkce.code_challenge_method,
353
+ resource: server_url
354
+ }.compact
355
+
356
+ uri = URI.parse(server_metadata.authorization_endpoint)
357
+ uri.query = URI.encode_www_form(params)
358
+ uri.to_s
359
+ end
360
+
361
+ # Exchange authorization code for access token
362
+ # @param server_metadata [ServerMetadata] Server metadata
363
+ # @param client_info [ClientInfo] Client information
364
+ # @param code [String] Authorization code
365
+ # @param pkce [PKCE] PKCE parameters
366
+ # @return [Token] Access token
367
+ # @raise [MCPClient::Errors::ConnectionError] if token exchange fails
368
+ def exchange_authorization_code(server_metadata, client_info, code, pkce)
369
+ logger.debug("Exchanging authorization code for token at: #{server_metadata.token_endpoint}")
370
+
371
+ params = {
372
+ grant_type: 'authorization_code',
373
+ code: code,
374
+ redirect_uri: redirect_uri,
375
+ client_id: client_info.client_id,
376
+ code_verifier: pkce.code_verifier,
377
+ resource: server_url
378
+ }
379
+
380
+ response = @http_client.post(server_metadata.token_endpoint) do |req|
381
+ req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
382
+ req.headers['Accept'] = 'application/json'
383
+ req.body = URI.encode_www_form(params)
384
+ end
385
+
386
+ unless response.success?
387
+ raise MCPClient::Errors::ConnectionError, "Token exchange failed: HTTP #{response.status} - #{response.body}"
388
+ end
389
+
390
+ data = JSON.parse(response.body)
391
+ Token.new(
392
+ access_token: data['access_token'],
393
+ token_type: data['token_type'] || 'Bearer',
394
+ expires_in: data['expires_in'],
395
+ scope: data['scope'],
396
+ refresh_token: data['refresh_token']
397
+ )
398
+ rescue JSON::ParserError => e
399
+ raise MCPClient::Errors::ConnectionError, "Invalid token response: #{e.message}"
400
+ rescue Faraday::Error => e
401
+ raise MCPClient::Errors::ConnectionError, "Network error during token exchange: #{e.message}"
402
+ end
403
+
404
+ # Refresh access token
405
+ # @param token [Token] Current token with refresh token
406
+ # @return [Token, nil] New access token or nil if refresh failed
407
+ def refresh_token(token)
408
+ return nil unless token.refresh_token
409
+
410
+ logger.debug('Refreshing access token')
411
+
412
+ server_metadata = discover_authorization_server
413
+ client_info = storage.get_client_info(server_url)
414
+
415
+ return nil unless server_metadata && client_info
416
+
417
+ params = {
418
+ grant_type: 'refresh_token',
419
+ refresh_token: token.refresh_token,
420
+ client_id: client_info.client_id,
421
+ resource: server_url
422
+ }
423
+
424
+ response = @http_client.post(server_metadata.token_endpoint) do |req|
425
+ req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
426
+ req.headers['Accept'] = 'application/json'
427
+ req.body = URI.encode_www_form(params)
428
+ end
429
+
430
+ unless response.success?
431
+ logger.warn("Token refresh failed: HTTP #{response.status}")
432
+ return nil
433
+ end
434
+
435
+ data = JSON.parse(response.body)
436
+ new_token = Token.new(
437
+ access_token: data['access_token'],
438
+ token_type: data['token_type'] || 'Bearer',
439
+ expires_in: data['expires_in'],
440
+ scope: data['scope'],
441
+ refresh_token: data['refresh_token'] || token.refresh_token
442
+ )
443
+
444
+ storage.set_token(server_url, new_token)
445
+ new_token
446
+ rescue JSON::ParserError => e
447
+ logger.warn("Invalid token refresh response: #{e.message}")
448
+ nil
449
+ rescue Faraday::Error => e
450
+ logger.warn("Network error during token refresh: #{e.message}")
451
+ nil
452
+ end
453
+
454
+ # Simple in-memory storage for OAuth data
455
+ class MemoryStorage
456
+ def initialize
457
+ @tokens = {}
458
+ @client_infos = {}
459
+ @server_metadata = {}
460
+ @pkce_data = {}
461
+ @state_data = {}
462
+ end
463
+
464
+ def get_token(server_url)
465
+ @tokens[server_url]
466
+ end
467
+
468
+ def set_token(server_url, token)
469
+ @tokens[server_url] = token
470
+ end
471
+
472
+ def get_client_info(server_url)
473
+ @client_infos[server_url]
474
+ end
475
+
476
+ def set_client_info(server_url, client_info)
477
+ @client_infos[server_url] = client_info
478
+ end
479
+
480
+ def get_server_metadata(server_url)
481
+ @server_metadata[server_url]
482
+ end
483
+
484
+ def set_server_metadata(server_url, metadata)
485
+ @server_metadata[server_url] = metadata
486
+ end
487
+
488
+ def get_pkce(server_url)
489
+ @pkce_data[server_url]
490
+ end
491
+
492
+ def set_pkce(server_url, pkce)
493
+ @pkce_data[server_url] = pkce
494
+ end
495
+
496
+ def delete_pkce(server_url)
497
+ @pkce_data.delete(server_url)
498
+ end
499
+
500
+ def get_state(server_url)
501
+ @state_data[server_url]
502
+ end
503
+
504
+ def set_state(server_url, state)
505
+ @state_data[server_url] = state
506
+ end
507
+
508
+ def delete_state(server_url)
509
+ @state_data.delete(server_url)
510
+ end
511
+ end
512
+ end
513
+ end
514
+ end