Class: RubyLLM::MCP::Auth::OAuthProvider
- Inherits:
-
Object
- Object
- RubyLLM::MCP::Auth::OAuthProvider
- Defined in:
- lib/ruby_llm/mcp/auth/oauth_provider.rb
Overview
This class is not thread-safe. Each thread should use its own instance.
Core OAuth 2.1 provider implementing complete authorization flow Supports RFC 7636 (PKCE), RFC 7591 (Dynamic Registration), RFC 8414 (Server Metadata), RFC 8707 (Resource Indicators), RFC 9728 (Protected Resource Metadata)
Instance Attribute Summary collapse
-
#grant_type ⇒ Object
Returns the value of attribute grant_type.
-
#logger ⇒ Object
Returns the value of attribute logger.
-
#redirect_uri ⇒ Object
Returns the value of attribute redirect_uri.
-
#scope ⇒ Object
Returns the value of attribute scope.
-
#server_url ⇒ Object
readonly
Returns the value of attribute server_url.
-
#storage ⇒ Object
Returns the value of attribute storage.
Class Method Summary collapse
-
.normalize_url(url) ⇒ String
Normalize server URL for consistent comparison.
Instance Method Summary collapse
-
#access_token ⇒ Token?
Get current access token, refreshing if needed.
-
#apply_authorization(request) ⇒ Object
Apply authorization header to HTTP request.
-
#authenticate ⇒ Token
Authenticate and return current access token This is a convenience method for consistency with BrowserOAuthProvider For standard OAuth flow, external authorization is required before calling this.
-
#client_credentials_flow(scope: nil, resource_metadata: nil) ⇒ Token
Perform client credentials flow (application authentication without user).
-
#complete_authorization_flow(code, state) ⇒ Token
Complete OAuth authorization flow after callback.
-
#handle_authentication_challenge(www_authenticate: nil, resource_metadata: nil, resource_metadata_url: nil, requested_scope: nil) ⇒ Boolean
Handle authentication challenge from server (401 response) Attempts to refresh token or raises error if interactive auth required.
-
#initialize(server_url:, redirect_uri: "http://localhost:8080/callback", scope: nil, logger: nil, storage: nil, grant_type: :authorization_code) ⇒ OAuthProvider
constructor
rubocop:disable Metrics/ParameterLists.
-
#parse_www_authenticate(header) ⇒ Hash
Parse WWW-Authenticate header to extract challenge parameters.
-
#start_authorization_flow(resource_metadata: nil) ⇒ String
Start OAuth authorization flow (authorization code grant).
Constructor Details
#initialize(server_url:, redirect_uri: "http://localhost:8080/callback", scope: nil, logger: nil, storage: nil, grant_type: :authorization_code) ⇒ OAuthProvider
rubocop:disable Metrics/ParameterLists
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 38 def initialize(server_url:, redirect_uri: "http://localhost:8080/callback", scope: nil, logger: nil, # rubocop:disable Metrics/ParameterLists storage: nil, grant_type: :authorization_code) self.server_url = server_url self.redirect_uri = redirect_uri self.scope = scope self.logger = logger || MCP.logger self.storage = storage || MemoryStorage.new self.grant_type = grant_type.to_sym validate_redirect_uri!(redirect_uri) # Initialize HTTP client @http_client = create_http_client # Initialize service objects @discoverer = Discoverer.new(@http_client, self.storage, self.logger) @client_registrar = ClientRegistrar.new(@http_client, self.storage, self.logger, MCP.config) @token_manager = TokenManager.new(@http_client, self.logger) @session_manager = SessionManager.new(self.storage) # Initialize flow orchestrators @auth_code_flow = Flows::AuthorizationCodeFlow.new( discoverer: @discoverer, client_registrar: @client_registrar, session_manager: @session_manager, token_manager: @token_manager, storage: self.storage, logger: self.logger ) @client_creds_flow = Flows::ClientCredentialsFlow.new( discoverer: @discoverer, client_registrar: @client_registrar, token_manager: @token_manager, storage: self.storage, logger: self.logger ) end |
Instance Attribute Details
#grant_type ⇒ Object
Returns the value of attribute grant_type.
13 14 15 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 13 def grant_type @grant_type end |
#logger ⇒ Object
Returns the value of attribute logger.
13 14 15 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 13 def logger @logger end |
#redirect_uri ⇒ Object
Returns the value of attribute redirect_uri.
13 14 15 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 13 def redirect_uri @redirect_uri end |
#scope ⇒ Object
Returns the value of attribute scope.
13 14 15 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 13 def scope @scope end |
#server_url ⇒ Object
Returns the value of attribute server_url.
12 13 14 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 12 def server_url @server_url end |
#storage ⇒ Object
Returns the value of attribute storage.
13 14 15 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 13 def storage @storage end |
Class Method Details
.normalize_url(url) ⇒ String
Normalize server URL for consistent comparison
18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 18 def self.normalize_url(url) uri = URI.parse(url) uri.scheme = uri.scheme&.downcase uri.host = uri.host&.downcase if (uri.scheme == "http" && uri.port == 80) || (uri.scheme == "https" && uri.port == 443) uri.port = nil end if uri.path.nil? || uri.path.empty? || uri.path == "/" uri.path = "" elsif uri.path.end_with?("/") uri.path = uri.path.chomp("/") end uri.fragment = nil uri.to_s end |
Instance Method Details
#access_token ⇒ Token?
Get current access token, refreshing if needed
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 77 def access_token logger.debug("OAuth access_token: Looking up token for server_url='#{server_url}'") token = storage.get_token(server_url) logger.debug("OAuth access_token: Storage returned token=#{token ? 'present' : 'nil'}") if token logger.debug(" Token expires_at: #{token.expires_at}") logger.debug(" Token expired?: #{token.expired?}") logger.debug(" Token expires_soon?: #{token.expires_soon?}") else logger.warn("✗ No token found in storage for server_url='#{server_url}'") logger.warn(" Check that authentication completed and stored the token") return nil end # Return token if still valid return token unless token.expired? || token.expires_soon? # Try to refresh if we have a refresh token logger.debug("Token expired or expiring soon, attempting refresh...") refresh_token(token) if token.refresh_token end |
#apply_authorization(request) ⇒ Object
Apply authorization header to HTTP request
154 155 156 157 158 159 160 161 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 154 def (request) token = access_token logger.debug("OAuth apply_authorization: token=#{token ? 'present' : 'nil'}") return unless token logger.debug("OAuth applying authorization header: #{token.to_header[0..20]}...") request.headers["Authorization"] = token.to_header end |
#authenticate ⇒ Token
Authenticate and return current access token This is a convenience method for consistency with BrowserOAuthProvider For standard OAuth flow, external authorization is required before calling this
105 106 107 108 109 110 111 112 113 114 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 105 def authenticate token = access_token unless token raise Errors::TransportError.new( message: "Not authenticated. Please complete OAuth authorization flow first. " \ "For standard OAuth, you must authorize externally and exchange the code." ) end token end |
#client_credentials_flow(scope: nil, resource_metadata: nil) ⇒ Token
Perform client credentials flow (application authentication without user)
134 135 136 137 138 139 140 141 142 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 134 def client_credentials_flow(scope: nil, resource_metadata: nil) hint = || @resource_metadata_hint @client_creds_flow.execute( server_url, redirect_uri, scope || self.scope, resource_metadata: hint ) end |
#complete_authorization_flow(code, state) ⇒ Token
Complete OAuth authorization flow after callback
148 149 150 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 148 def (code, state) @auth_code_flow.complete(server_url, code, state) end |
#handle_authentication_challenge(www_authenticate: nil, resource_metadata: nil, resource_metadata_url: nil, requested_scope: nil) ⇒ Boolean
Handle authentication challenge from server (401 response) Attempts to refresh token or raises error if interactive auth required
171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 171 def handle_authentication_challenge(www_authenticate: nil, resource_metadata: nil, resource_metadata_url: nil, requested_scope: nil) = || logger.debug("Handling authentication challenge") logger.debug(" WWW-Authenticate: #{www_authenticate}") if www_authenticate logger.debug(" Resource metadata URL: #{}") if logger.debug(" Requested scope: #{requested_scope}") if requested_scope final_requested_scope, = resolve_challenge_context( www_authenticate, , , requested_scope ) # Persist discovery hint for browser-based fallback flows. @resource_metadata_hint = if update_scope_if_needed(final_requested_scope) # Try to refresh existing token token = storage.get_token(server_url) if token&.refresh_token logger.debug("Attempting token refresh with existing refresh token") refreshed_token = refresh_token(token, resource_metadata: ) return true if refreshed_token end # If we have client credentials, try that flow if grant_type == :client_credentials logger.debug("Attempting client credentials flow") begin new_token = client_credentials_flow( scope: final_requested_scope, resource_metadata: ) return true if new_token rescue StandardError => e logger.warn("Client credentials flow failed: #{e.}") end end # Cannot automatically authenticate - interactive auth required logger.warn("Cannot automatically authenticate - interactive authorization required") raise Errors::AuthenticationRequiredError.new( message: "OAuth authentication required. Token refresh failed and interactive authorization is needed." ) end |
#parse_www_authenticate(header) ⇒ Hash
Parse WWW-Authenticate header to extract challenge parameters
223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 223 def parse_www_authenticate(header) result = {} # Example: Bearer realm="example", scope="mcp:read mcp:write", resource_metadata="https://..." if header =~ /Bearer\s+(.+)/i params = ::Regexp.last_match(1) parsed_params = {} params.scan(/([a-zA-Z_][a-zA-Z0-9_-]*)="([^"]*)"/) do |key, value| parsed_params[key.downcase] = value end # Extract scope result[:scope] = parsed_params["scope"] if parsed_params["scope"] # Extract resource metadata URL (spec + legacy alias) result[:resource_metadata] = parsed_params["resource_metadata"] || parsed_params["resource_metadata_url"] result[:resource_metadata_url] = result[:resource_metadata] if result[:resource_metadata] # Extract realm result[:realm] = parsed_params["realm"] if parsed_params["realm"] end result end |
#start_authorization_flow(resource_metadata: nil) ⇒ String
Start OAuth authorization flow (authorization code grant)
119 120 121 122 123 124 125 126 127 128 |
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 119 def (resource_metadata: nil) hint = || @resource_metadata_hint @auth_code_flow.start( server_url, redirect_uri, scope, resource_metadata: hint, https_validator: method(:validate_https_endpoint) ) end |