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,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
|