Class: RubyLLM::MCP::Auth::OAuthProvider

Inherits:
Object
  • Object
show all
Defined in:
lib/ruby_llm/mcp/auth/oauth_provider.rb

Overview

Note:

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

Class Method Summary collapse

Instance Method Summary collapse

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_typeObject

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

#loggerObject

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_uriObject

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

#scopeObject

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_urlObject

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

#storageObject

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

Parameters:

  • url (String)

    raw URL

Returns:

  • (String)

    normalized URL



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_tokenToken?

Get current access token, refreshing if needed

Returns:

  • (Token, nil)

    valid access token or nil



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

Parameters:

  • request (HTTPX::Request)

    HTTP request object



154
155
156
157
158
159
160
161
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 154

def apply_authorization(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

#authenticateToken

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

Returns:

  • (Token)

    current valid access token

Raises:



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)

Parameters:

  • scope (String) (defaults to: nil)

    optional scope override

  • resource_metadata (String, nil) (defaults to: nil)

    explicit resource metadata URL hint

Returns:

  • (Token)

    access token



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

Parameters:

  • code (String)

    authorization code from callback

  • state (String)

    state parameter from callback

Returns:

  • (Token)

    access token



148
149
150
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 148

def complete_authorization_flow(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

Parameters:

  • www_authenticate (String, nil) (defaults to: nil)

    WWW-Authenticate header value

  • resource_metadata (String, nil) (defaults to: nil)

    Resource metadata URL from response/challenge

  • resource_metadata_url (String, nil) (defaults to: nil)

    Legacy alias for resource_metadata

  • requested_scope (String, nil) (defaults to: nil)

    Scope from WWW-Authenticate challenge

Returns:

  • (Boolean)

    true if authentication was refreshed successfully

Raises:



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.message}")
    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

Parameters:

  • header (String)

    WWW-Authenticate header value

Returns:

  • (Hash)

    parsed challenge information



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)

Parameters:

  • resource_metadata (String, nil) (defaults to: nil)

    explicit resource metadata URL hint

Returns:

  • (String)

    authorization URL for user to visit



119
120
121
122
123
124
125
126
127
128
# File 'lib/ruby_llm/mcp/auth/oauth_provider.rb', line 119

def start_authorization_flow(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