Class: MCPClient::Auth::BrowserOAuth

Inherits:
Object
  • Object
show all
Defined in:
lib/mcp_client/auth/browser_oauth.rb

Overview

Browser-based OAuth authentication flow helper Provides a complete OAuth flow using browser authentication with a local callback server

Defined Under Namespace

Classes: CallbackServer

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(oauth_provider, callback_port: 8080, callback_path: '/callback', logger: nil) ⇒ BrowserOAuth

Initialize browser OAuth helper

Parameters:

  • oauth_provider (OAuthProvider)

    OAuth provider to use for authentication

  • callback_port (Integer) (defaults to: 8080)

    Port for local callback server (default: 8080)

  • callback_path (String) (defaults to: '/callback')

    Path for OAuth callback (default: ‘/callback’)

  • logger (Logger, nil) (defaults to: nil)

    Optional logger



28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/mcp_client/auth/browser_oauth.rb', line 28

def initialize(oauth_provider, callback_port: 8080, callback_path: '/callback', logger: nil)
  @oauth_provider = oauth_provider
  @callback_port = callback_port
  @callback_path = callback_path
  @logger = logger || Logger.new($stdout, level: Logger::WARN)

  # Ensure OAuth provider's redirect_uri matches our callback server
  expected_redirect_uri = "http://localhost:#{callback_port}#{callback_path}"
  return unless oauth_provider.redirect_uri != expected_redirect_uri

  @logger.warn("OAuth provider redirect_uri (#{oauth_provider.redirect_uri}) doesn't match " \
               "callback server (#{expected_redirect_uri}). Updating redirect_uri.")
  oauth_provider.redirect_uri = expected_redirect_uri
end

Instance Attribute Details

#callback_pathString (readonly)

Returns Path for OAuth callback.

Returns:

  • (String)

    Path for OAuth callback



21
# File 'lib/mcp_client/auth/browser_oauth.rb', line 21

attr_reader :oauth_provider, :callback_port, :callback_path, :logger

#callback_portInteger (readonly)

Returns Port for local callback server.

Returns:

  • (Integer)

    Port for local callback server



21
# File 'lib/mcp_client/auth/browser_oauth.rb', line 21

attr_reader :oauth_provider, :callback_port, :callback_path, :logger

#loggerObject (readonly)

Returns the value of attribute logger.



21
# File 'lib/mcp_client/auth/browser_oauth.rb', line 21

attr_reader :oauth_provider, :callback_port, :callback_path, :logger

#oauth_providerOAuthProvider (readonly)

Returns The OAuth provider instance.

Returns:



21
22
23
# File 'lib/mcp_client/auth/browser_oauth.rb', line 21

def oauth_provider
  @oauth_provider
end

Instance Method Details

#authenticate(timeout: 300, auto_open_browser: true) ⇒ Token

Perform complete browser-based OAuth authentication flow This will:

  1. Start a local HTTP server to handle the callback

  2. Open the authorization URL in the user’s browser

  3. Wait for the user to authorize and receive the callback

  4. Complete the OAuth flow and return the token

Parameters:

  • timeout (Integer) (defaults to: 300)

    Timeout in seconds to wait for callback (default: 300 = 5 minutes)

  • auto_open_browser (Boolean) (defaults to: true)

    Automatically open browser (default: true)

Returns:

  • (Token)

    Access token after successful authentication

Raises:



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/mcp_client/auth/browser_oauth.rb', line 54

def authenticate(timeout: 300, auto_open_browser: true)
  # Start authorization flow and get URL
  auth_url = @oauth_provider.start_authorization_flow
  @logger.debug("Authorization URL: #{auth_url}")

  # Create a result container to share data between threads
  result = { code: nil, state: nil, error: nil, completed: false }
  mutex = Mutex.new
  condition = ConditionVariable.new

  # Start local callback server
  server = start_callback_server(result, mutex, condition)

  begin
    # Open browser to authorization URL
    if auto_open_browser
      open_browser(auth_url)
      @logger.info("\nOpening browser for authorization...")
      @logger.info("If browser doesn't open automatically, visit this URL:")
    else
      @logger.info("\nPlease visit this URL to authorize:")
    end
    @logger.info(auth_url)
    @logger.info("\nWaiting for authorization...")

    # Wait for callback with timeout
    mutex.synchronize do
      condition.wait(mutex, timeout) unless result[:completed]
    end

    # Check if we got a response
    raise Timeout::Error, "OAuth authorization timed out after #{timeout} seconds" unless result[:completed]

    # Check for errors
    raise MCPClient::Errors::ConnectionError, "OAuth authorization failed: #{result[:error]}" if result[:error]

    # Complete OAuth flow
    @logger.debug('Completing OAuth authorization flow')
    token = @oauth_provider.complete_authorization_flow(result[:code], result[:state])

    @logger.info("\nAuthentication successful!")
    token
  ensure
    # Always shutdown the server
    server&.shutdown
  end
end

#handle_http_request(client, result, mutex, condition) ⇒ Object

Handle HTTP request from OAuth callback

Parameters:

  • client (TCPSocket)

    The client socket

  • result (Hash)

    Hash to store callback results

  • mutex (Mutex)

    Mutex for thread synchronization

  • condition (ConditionVariable)

    Condition variable for thread signaling



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
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
219
# File 'lib/mcp_client/auth/browser_oauth.rb', line 152

def handle_http_request(client, result, mutex, condition)
  # Set read timeout to prevent hanging connections
  client.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [5, 0].pack('l_2'))

  # Read request line
  request_line = client.gets
  return unless request_line

  parts = request_line.split
  return unless parts.length >= 2

  method, path = parts[0..1]
  @logger.debug("Received #{method} request: #{path}")

  # Read and discard headers until blank line (with limit to prevent memory exhaustion)
  header_count = 0
  loop do
    break if header_count >= 100 # Limit header count

    line = client.gets
    break if line.nil? || line.strip.empty?

    header_count += 1
  end

  # Parse path and query parameters
  uri_path, query_string = path.split('?', 2)

  # Only handle our callback path
  unless uri_path == @callback_path
    send_http_response(client, 404, 'text/plain', 'Not Found')
    return
  end

  # Parse query parameters
  params = parse_query_params(query_string || '')
  @logger.debug("Callback params: #{params.keys.join(', ')}")

  # Extract OAuth parameters
  code = params['code']
  state = params['state']
  error = params['error']
  error_description = params['error_description']

  # Update result and signal waiting thread
  mutex.synchronize do
    if error
      result[:error] = error_description || error
    elsif code && state
      result[:code] = code
      result[:state] = state
    else
      result[:error] = 'Invalid callback: missing code or state parameter'
    end
    result[:completed] = true

    condition.signal
  end

  # Send HTML response to browser
  if result[:error]
    send_http_response(client, 400, 'text/html', error_page(result[:error]))
  else
    send_http_response(client, 200, 'text/html', success_page)
  end
ensure
  client&.close
end

#open_browser(url) ⇒ Boolean

Open URL in default browser

Parameters:

  • url (String)

    URL to open

Returns:

  • (Boolean)

    true if browser opened successfully



263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/mcp_client/auth/browser_oauth.rb', line 263

def open_browser(url)
  case RbConfig::CONFIG['host_os']
  when /darwin/
    system('open', url)
  when /linux|bsd/
    system('xdg-open', url)
  when /mswin|mingw|cygwin/
    system('start', url)
  else
    @logger.warn('Unknown operating system, cannot open browser automatically')
    false
  end
rescue StandardError => e
  @logger.warn("Failed to open browser: #{e.message}")
  false
end

#parse_query_params(query_string) ⇒ Hash

Parse URL query parameters

Parameters:

  • query_string (String)

    Query string from URL

Returns:

  • (Hash)

    Parsed parameters



225
226
227
228
229
230
231
232
233
234
# File 'lib/mcp_client/auth/browser_oauth.rb', line 225

def parse_query_params(query_string)
  params = {}
  query_string.split('&').each do |param|
    next if param.empty?

    key, value = param.split('=', 2)
    params[CGI.unescape(key)] = CGI.unescape(value || '')
  end
  params
end

#send_http_response(client, status_code, content_type, body) ⇒ Object

Send HTTP response to client

Parameters:

  • client (TCPSocket)

    The client socket

  • status_code (Integer)

    HTTP status code

  • content_type (String)

    Content type header value

  • body (String)

    Response body



242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/mcp_client/auth/browser_oauth.rb', line 242

def send_http_response(client, status_code, content_type, body)
  status_text = case status_code
                when 200 then 'OK'
                when 400 then 'Bad Request'
                when 404 then 'Not Found'
                else 'Unknown'
                end

  response = "HTTP/1.1 #{status_code} #{status_text}\r\n"
  response += "Content-Type: #{content_type}; charset=utf-8\r\n"
  response += "Content-Length: #{body.bytesize}\r\n"
  response += "Connection: close\r\n"
  response += "\r\n"
  response += body

  client.print(response)
end

#start_callback_server(result, mutex, condition) ⇒ CallbackServer

Start the local callback server using TCPServer

Parameters:

  • result (Hash)

    Hash to store callback results

  • mutex (Mutex)

    Mutex for thread synchronization

  • condition (ConditionVariable)

    Condition variable for thread signaling

Returns:

Raises:



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/mcp_client/auth/browser_oauth.rb', line 109

def start_callback_server(result, mutex, condition)
  begin
    server = TCPServer.new('127.0.0.1', @callback_port)
    @logger.debug("Started callback server on http://127.0.0.1:#{@callback_port}#{@callback_path}")
  rescue Errno::EADDRINUSE
    raise MCPClient::Errors::ConnectionError,
          "Cannot start OAuth callback server: port #{@callback_port} is already in use. " \
          'Please close the application using this port or choose a different callback_port.'
  rescue StandardError => e
    raise MCPClient::Errors::ConnectionError,
          "Failed to start OAuth callback server on port #{@callback_port}: #{e.message}"
  end

  running = true

  # Start server in background thread
  thread = Thread.new do
    while running
      begin
        # Use wait_readable with timeout to allow checking the running flag
        next unless server.wait_readable(0.5)

        client = server.accept
        handle_http_request(client, result, mutex, condition)
      rescue IOError, Errno::EBADF
        # Server was closed, exit loop
        break
      rescue StandardError => e
        @logger.error("Error handling callback request: #{e.message}")
      end
    end
  end

  # Return an object with shutdown method for compatibility
  CallbackServer.new(server, thread, -> { running = false })
end