Skip to content

Commit 92d4f4c

Browse files
authored
Merge pull request #97 from patvice/oauth-challenge-handling
OAuth challenge handling with automatic retry
2 parents 6a736ea + a373847 commit 92d4f4c

File tree

12 files changed

+907
-14
lines changed

12 files changed

+907
-14
lines changed

lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ def initialize(url:, headers: {}, version: :http2, request_timeout: 10_000)
1212

1313
@native_transport = RubyLLM::MCP::Native::Transports::SSE.new(
1414
url: url,
15-
headers: headers,
16-
version: version,
1715
coordinator: @coordinator,
18-
request_timeout: request_timeout
16+
request_timeout: request_timeout,
17+
options: {
18+
headers: headers,
19+
version: version
20+
}
1921
)
2022
end
2123

lib/ruby_llm/mcp/auth/browser_oauth_provider.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,38 @@ def complete_authorization_flow(code, state)
155155
@oauth_provider.complete_authorization_flow(code, state)
156156
end
157157

158+
# Handle authentication challenge with browser-based auth
159+
# @param www_authenticate [String, nil] WWW-Authenticate header value
160+
# @param resource_metadata_url [String, nil] Resource metadata URL from response
161+
# @param requested_scope [String, nil] Scope from WWW-Authenticate challenge
162+
# @return [Boolean] true if authentication was completed successfully
163+
def handle_authentication_challenge(www_authenticate: nil, resource_metadata_url: nil, requested_scope: nil)
164+
@logger.debug("BrowserOAuthProvider handling authentication challenge")
165+
166+
# Try standard provider's automatic handling first (token refresh, client credentials)
167+
begin
168+
return @oauth_provider.handle_authentication_challenge(
169+
www_authenticate: www_authenticate,
170+
resource_metadata_url: resource_metadata_url,
171+
requested_scope: requested_scope
172+
)
173+
rescue Errors::AuthenticationRequiredError
174+
# Standard provider couldn't handle it - need interactive auth
175+
@logger.info("Automatic authentication failed, starting browser-based OAuth flow")
176+
end
177+
178+
# Perform full browser-based authentication
179+
authenticate(auto_open_browser: true)
180+
true
181+
end
182+
183+
# Parse WWW-Authenticate header (delegate to oauth_provider)
184+
# @param header [String] WWW-Authenticate header value
185+
# @return [Hash] parsed challenge information
186+
def parse_www_authenticate(header)
187+
@oauth_provider.parse_www_authenticate(header)
188+
end
189+
158190
private
159191

160192
# Validate and synchronize redirect_uri between this provider and oauth_provider

lib/ruby_llm/mcp/auth/memory_storage.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def initialize
1212
@server_metadata = {}
1313
@pkce_data = {}
1414
@state_data = {}
15+
@resource_metadata = {}
1516
end
1617

1718
# Token storage
@@ -23,6 +24,10 @@ def set_token(server_url, token)
2324
@tokens[server_url] = token
2425
end
2526

27+
def delete_token(server_url)
28+
@tokens.delete(server_url)
29+
end
30+
2631
# Client registration storage
2732
def get_client_info(server_url)
2833
@client_infos[server_url]
@@ -66,6 +71,19 @@ def set_state(server_url, state)
6671
def delete_state(server_url)
6772
@state_data.delete(server_url)
6873
end
74+
75+
# Resource metadata management
76+
def get_resource_metadata(server_url)
77+
@resource_metadata[server_url]
78+
end
79+
80+
def set_resource_metadata(server_url, metadata)
81+
@resource_metadata[server_url] = metadata
82+
end
83+
84+
def delete_resource_metadata(server_url)
85+
@resource_metadata.delete(server_url)
86+
end
6987
end
7088
end
7189
end

lib/ruby_llm/mcp/auth/oauth_provider.rb

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,88 @@ def apply_authorization(request)
150150
request.headers["Authorization"] = token.to_header
151151
end
152152

153+
# Handle authentication challenge from server (401 response)
154+
# Attempts to refresh token or raises error if interactive auth required
155+
# @param www_authenticate [String, nil] WWW-Authenticate header value
156+
# @param resource_metadata_url [String, nil] Resource metadata URL from response
157+
# @param requested_scope [String, nil] Scope from WWW-Authenticate challenge
158+
# @return [Boolean] true if authentication was refreshed successfully
159+
# @raise [Errors::AuthenticationRequiredError] if interactive auth is required
160+
def handle_authentication_challenge(www_authenticate: nil, resource_metadata_url: nil, requested_scope: nil)
161+
logger.debug("Handling authentication challenge")
162+
logger.debug(" WWW-Authenticate: #{www_authenticate}") if www_authenticate
163+
logger.debug(" Resource metadata URL: #{resource_metadata_url}") if resource_metadata_url
164+
logger.debug(" Requested scope: #{requested_scope}") if requested_scope
165+
166+
# Parse WWW-Authenticate header if provided
167+
final_requested_scope = requested_scope
168+
if www_authenticate
169+
challenge_info = parse_www_authenticate(www_authenticate)
170+
final_requested_scope ||= challenge_info[:scope]
171+
# NOTE: resource_metadata_url from challenge_info could be used for future discovery
172+
end
173+
174+
# Update scope if server requested different scope
175+
if final_requested_scope && final_requested_scope != scope
176+
logger.debug("Updating scope from '#{scope}' to '#{final_requested_scope}'")
177+
self.scope = final_requested_scope
178+
end
179+
180+
# Try to refresh existing token
181+
token = storage.get_token(server_url)
182+
if token&.refresh_token
183+
logger.debug("Attempting token refresh with existing refresh token")
184+
refreshed_token = refresh_token(token)
185+
return true if refreshed_token
186+
end
187+
188+
# If we have client credentials, try that flow
189+
if grant_type == :client_credentials
190+
logger.debug("Attempting client credentials flow")
191+
begin
192+
new_token = client_credentials_flow(scope: requested_scope)
193+
return true if new_token
194+
rescue StandardError => e
195+
logger.warn("Client credentials flow failed: #{e.message}")
196+
end
197+
end
198+
199+
# Cannot automatically authenticate - interactive auth required
200+
logger.warn("Cannot automatically authenticate - interactive authorization required")
201+
raise Errors::AuthenticationRequiredError.new(
202+
message: "OAuth authentication required. Token refresh failed and interactive authorization is needed."
203+
)
204+
end
205+
206+
# Parse WWW-Authenticate header to extract challenge parameters
207+
# @param header [String] WWW-Authenticate header value
208+
# @return [Hash] parsed challenge information
209+
def parse_www_authenticate(header)
210+
result = {}
211+
212+
# Example: Bearer realm="example", scope="mcp:read mcp:write", resource_metadata_url="https://..."
213+
if header =~ /Bearer\s+(.+)/i
214+
params = ::Regexp.last_match(1)
215+
216+
# Extract scope
217+
if params =~ /scope="([^"]+)"/
218+
result[:scope] = ::Regexp.last_match(1)
219+
end
220+
221+
# Extract resource metadata URL
222+
if params =~ /resource_metadata_url="([^"]+)"/
223+
result[:resource_metadata_url] = ::Regexp.last_match(1)
224+
end
225+
226+
# Extract realm
227+
if params =~ /realm="([^"]+)"/
228+
result[:realm] = ::Regexp.last_match(1)
229+
end
230+
end
231+
232+
result
233+
end
234+
153235
private
154236

155237
# Create HTTP client for OAuth requests

lib/ruby_llm/mcp/native/transport.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,23 @@ def build_transport
5858
transport_config.merge!(options)
5959
end
6060

61+
# Handle SSE transport specially - it uses options hash pattern
62+
if transport_type == :sse
63+
url = transport_config.delete(:url) || transport_config.delete("url")
64+
request_timeout = transport_config.delete(:request_timeout) ||
65+
transport_config.delete("request_timeout") ||
66+
MCP.config.request_timeout
67+
# Everything else goes into options
68+
options_hash = transport_config.dup
69+
transport_config.clear
70+
transport_config[:url] = url
71+
transport_config[:request_timeout] = request_timeout
72+
transport_config[:options] = options_hash
73+
end
74+
6175
# Remove OAuth-specific params from transports that don't support them
6276
# This allows other arbitrary params (like timeout) to pass through for testing
63-
unless %i[streamable streamable_http].include?(transport_type)
77+
unless %i[streamable streamable_http sse].include?(transport_type)
6478
transport_config.delete(:oauth_provider)
6579
transport_config.delete(:oauth)
6680
end

0 commit comments

Comments
 (0)