diff --git a/docs/security.md b/docs/security.md index 2184be0..b82cd3c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -6,8 +6,16 @@ Security is a critical aspect of any application that exposes functionality thro - [DNS Rebinding Protection](#dns-rebinding-protection) - [Authentication](#authentication) -- [HTTPS and SSL](#https-and-ssl) + - [Enabling Authentication](#enabling-authentication) + - [Authentication Strategies](#authentication-strategies) + - [Token-based Authentication](#1-token-based-authentication-default) + - [Custom Authentication Headers](#custom-authentication-headers) + - [Proc-based Authentication](#2-proc-based-authentication) + - [HTTP Basic Authentication](#3-http-basic-authentication) + - [Authentication Exemptions](#authentication-exemptions) + - [Authentication Environment Variables](#authentication-environment-variables) - [Best Practices](#best-practices) +- [Additional Resources](#additional-resources) ## DNS Rebinding Protection @@ -65,29 +73,181 @@ When a request arrives at the MCP endpoint, the RackTransport middleware: ## Authentication -Fast MCP supports token-based authentication for all connections to ensure only authorized clients can access your MCP server. +Fast MCP supports multiple authentication strategies to ensure only authorized clients can access your MCP server. -### Basic Authentication +### Enabling Authentication -To enable authentication, use the `authenticated_rack_middleware` method: +To enable authentication, set the `authenticate` option to `true` and provide the appropriate authentication options: ```ruby -# Enable authentication FastMcp.authenticated_rack_middleware(app, - auth_token: 'your-secret-token', - # other options... + authenticate: true, + auth_options: { + # Authentication configuration options... + } +) +``` + +In Rails applications, you can enable authentication in the initializer: + +```ruby +FastMcp.mount_in_rails( + Rails.application, + authenticate: true, + auth_options: { + # Authentication configuration options... + } +) +``` + +### Authentication Strategies + +Fast MCP supports three authentication strategies, configured within the `auth_options` hash: + +#### 1. Token-based Authentication (Default) + +The simplest authentication strategy that validates a token from the request header: + +```ruby +FastMcp.authenticated_rack_middleware(app, + authenticate: true, + auth_options: { + auth_strategy: :token, # This is the default if not specified + auth_token: 'your-secret-token', + auth_header: 'Authorization' # Optional, defaults to 'Authorization' + } +) +``` + +You can also use environment variables for your token: +```ruby +# Set MCP_AUTH_TOKEN in your environment +FastMcp.authenticated_rack_middleware(app, + authenticate: true, + auth_options: { + auth_strategy: :token + # Will use ENV['MCP_AUTH_TOKEN'] and ENV['MCP_AUTH_HEADER'] (defaults to 'Authorization') + } ) ``` ### Custom Authentication Headers -You can configure the header name used for authentication: +By default, Fast MCP uses the `Authorization` header for token-based authentication, but you can configure it to use any custom header. This is particularly useful for: + +1. Integration with API gateway services +2. Adding an additional security layer (security through obscurity) +3. Supporting multiple authentication schemes + +#### Using X-API-Key Header + +A common pattern for API authentication is to use the `X-API-Key` header instead of the standard `Authorization` header: ```ruby FastMcp.authenticated_rack_middleware(app, - auth_token: 'your-secret-token', - auth_header_name: 'X-API-Key', # Default is 'Authorization' - # other options... + authenticate: true, + auth_options: { + auth_strategy: :token, + auth_token: 'your-secret-token', + auth_header: 'X-API-Key' # Use X-API-Key header instead of Authorization + } +) +``` + +With this configuration, clients should send the API key directly in the header without the "Bearer" prefix: + +``` +X-API-Key: your-secret-token +``` + +#### Environment Variable Configuration + +You can also set the custom header using environment variables: + +```ruby +# In your environment: +# MCP_AUTH_HEADER=X-API-Key +# MCP_AUTH_TOKEN=your-secret-token + +FastMcp.authenticated_rack_middleware(app, + authenticate: true, + auth_options: { + auth_strategy: :token + # Will use ENV['MCP_AUTH_HEADER'] and ENV['MCP_AUTH_TOKEN'] + } +) +``` + +#### With Proc-based Authentication + +When using proc-based authentication, remember to access the correct header in your custom logic: + +```ruby +FastMcp.authenticated_rack_middleware(app, + authenticate: true, + auth_options: { + auth_strategy: :proc, + auth_proc: ->(request) { + # Access X-API-Key header + api_key = request.get_header('HTTP_X_API_KEY') + # Validate the API key + valid_keys = ['key1', 'key2', 'key3'] + valid_keys.include?(api_key) + } + } +) +``` + +#### 2. Proc-based Authentication + +For more complex authentication scenarios, you can use a proc that receives the entire request object: + +```ruby +FastMcp.authenticated_rack_middleware(app, + authenticate: true, + auth_options: { + auth_strategy: :proc, + auth_proc: ->(request) { + # Your custom authentication logic here + # Access the full request object for context + token = request.get_header('HTTP_AUTHORIZATION')&.gsub(/^Bearer\s+/i, '') + User.find_by(api_token: token).present? + } + } +) +``` + +This allows you to: +- Check tokens against your database +- Implement expiring tokens +- Validate user permissions +- Access request parameters for context-specific authentication +- Implement custom header or cookie-based authentication + +#### 3. HTTP Basic Authentication + +For applications that prefer username/password authentication: + +```ruby +FastMcp.authenticated_rack_middleware(app, + authenticate: true, + auth_options: { + auth_strategy: :http_basic, + auth_user: 'admin', # Username to accept + auth_password: 'secret' # Password to accept + } +) +``` + +You can also set these values using environment variables: +```ruby +# Set MCP_AUTH_USER and MCP_AUTH_PASSWORD in your environment +FastMcp.authenticated_rack_middleware(app, + authenticate: true, + auth_options: { + auth_strategy: :http_basic + # Will use ENV['MCP_AUTH_USER'] and ENV['MCP_AUTH_PASSWORD'] + } ) ``` @@ -97,12 +257,26 @@ Some paths can be exempted from authentication: ```ruby FastMcp.authenticated_rack_middleware(app, - auth_token: 'your-secret-token', - auth_exempt_paths: ['/health-check'], # Paths that don't require authentication - # other options... + authenticate: true, + auth_options: { + auth_strategy: :token, + auth_token: 'your-secret-token', + auth_exempt_paths: ['/health-check', '/mcp/public'] # Paths that don't require authentication + } ) ``` +### Authentication Environment Variables + +For security best practices, you can use environment variables for sensitive authentication information: + +| Environment Variable | Description | Default | +|---------------------|-------------|---------| +| `MCP_AUTH_TOKEN` | The token for token-based authentication | None (Required) | +| `MCP_AUTH_HEADER` | The header name for token-based auth | `Authorization` | +| `MCP_AUTH_USER` | The username for HTTP Basic authentication | None (Required) | +| `MCP_AUTH_PASSWORD` | The password for HTTP Basic authentication | None (Required) | + ## Best Practices Here are some best practices to enhance the security of your MCP server: @@ -110,7 +284,7 @@ Here are some best practices to enhance the security of your MCP server: 1. **Always validate Origin headers** (enabled by default) 2. **Use authentication** for all MCP endpoints in production 3. **Deploy behind HTTPS** in production environments -4. **Keep your auth_token secret** and rotate it regularly +4. **Keep your auth credentials in environment variables** rather than in code 5. **Implement proper error handling** to avoid leaking sensitive information 6. **Validate inputs thoroughly** in your tool implementations 7. **Implement rate limiting** for MCP endpoints to prevent abuse diff --git a/lib/fast_mcp.rb b/lib/fast_mcp.rb index cfd3dc6..2c01d75 100644 --- a/lib/fast_mcp.rb +++ b/lib/fast_mcp.rb @@ -65,7 +65,15 @@ def self.rack_middleware(app, options = {}) # @param options [Hash] Options for the middleware # @option options [String] :name The name of the server # @option options [String] :version The version of the server + # @option options [Boolean] :authenticate Whether to enable authentication + # @option options [Symbol] :authentication_strategy The authentication strategy to use (:token, :proc, or :http_basic) # @option options [String] :auth_token The authentication token + # @option options [Proc] :auth_proc A proc for custom authentication with behavior dependent on the strategy: + # - For :token strategy: proc takes (token, request) and returns a boolean + # - For :proc strategy: proc takes (token, request) and returns a boolean + # - For :http_basic strategy: proc takes (username, password, request) and returns a boolean + # @option options [String] :auth_header_name Custom header name for authentication (default: 'Authorization') + # @option options [Array] :auth_exempt_paths Paths that don't require authentication # @option options [Array] :allowed_origins List of allowed origins for DNS rebinding protection # @yield [server] A block to configure the server # @yieldparam server [FastMcp::Server] The server to configure @@ -125,8 +133,15 @@ def self.register_resources(*resources) # @option options [String] :messages_route The route for the messages endpoint # @option options [String] :sse_route The route for the SSE endpoint # @option options [Logger] :logger The logger to use - # @option options [Boolean] :authenticate Whether to use authentication + # @option options [Boolean] :authenticate Whether to enable authentication + # @option options [Symbol] :authentication_strategy The authentication strategy to use (:token, :proc, or :http_basic) # @option options [String] :auth_token The authentication token + # @option options [Proc] :auth_proc A proc for custom authentication with behavior dependent on the strategy: + # - For :token strategy: proc takes (token, request) and returns a boolean + # - For :proc strategy: proc takes (token, request) and returns a boolean + # - For :http_basic strategy: proc takes (username, password, request) and returns a boolean + # @option options [String] :auth_header_name Custom header name for authentication (default: 'Authorization') + # @option options [Array] :auth_exempt_paths Paths that don't require authentication # @option options [Array] :allowed_origins List of allowed origins for DNS rebinding protection # @yield [server] A block to configure the server # @yieldparam server [FastMcp::Server] The server to configure @@ -144,6 +159,7 @@ def self.mount_in_rails(app, options = {}) options[:logger] = logger options[:allowed_origins] = allowed_origins + options[:authenticate] = authenticate # Ensure authenticate flag is passed to middleware # Create or get the server self.server = FastMcp::Server.new(name: name, version: version, logger: logger) diff --git a/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb b/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb index 6194c1e..eb40c13 100644 --- a/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb +++ b/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb @@ -23,8 +23,45 @@ sse_route: 'sse' # This is the default route for the SSE endpoint # Add allowed origins below, it defaults to Rails.application.config.hosts # allowed_origins: ['localhost', '127.0.0.1', 'example.com', /.*\.example\.com/], - # authenticate: true, # Uncomment to enable authentication - # auth_token: 'your-token', # Required if authenticate: true + + # Authentication Configuration + # -------------------------- + # authenticate: true, # Uncomment to enable authentication + # auth_options: { + # # Choose one of the authentication strategies below: + # + # # 1. Token-based authentication (default) + # auth_strategy: :token, + # auth_token: 'your-secret-token', + # # auth_header: 'Authorization', # Optional, defaults to 'Authorization' + # # Using X-API-Key instead of Authorization header: + # # auth_header: 'X-API-Key', # Clients should send 'X-API-Key: your-secret-token' + # + # # 2. Proc-based authentication + # # auth_strategy: :proc, + # # auth_proc: ->(request) { + # # # Your custom authentication logic here + # # # The entire request object is available + # # token = request.get_header('HTTP_AUTHORIZATION')&.gsub(/^Bearer\s+/i, '') + # # User.find_by(api_token: token).present? + # # }, + # + # # 3. HTTP Basic Authentication + # # auth_strategy: :http_basic, + # # auth_user: 'admin', + # # auth_password: 'secret', + # + # # Additional Authentication Options + # # auth_exempt_paths: ['/health-check', '/mcp/public'], # Paths that don't require authentication + # }, + + # Environment Variables for Authentication + # --------------------------------------- + # Instead of hardcoding authentication details, you can use environment variables: + # - MCP_AUTH_TOKEN: The token for token-based authentication + # - MCP_AUTH_HEADER: The header name for token-based auth (defaults to 'Authorization') + # - MCP_AUTH_USER: The username for HTTP Basic authentication + # - MCP_AUTH_PASSWORD: The password for HTTP Basic authentication ) do |server| Rails.application.config.after_initialize do # FastMcp will automatically discover and register: diff --git a/lib/mcp/transports/authenticated_rack_transport.rb b/lib/mcp/transports/authenticated_rack_transport.rb index 2d887c3..19ea5e9 100644 --- a/lib/mcp/transports/authenticated_rack_transport.rb +++ b/lib/mcp/transports/authenticated_rack_transport.rb @@ -8,52 +8,85 @@ class AuthenticatedRackTransport < RackTransport def initialize(app, server, options = {}) super - @auth_token = options[:auth_token] - @auth_header_name = options[:auth_header_name] || 'Authorization' - @auth_exempt_paths = options[:auth_exempt_paths] || [] - @auth_enabled = !@auth_token.nil? + @auth_enabled = options[:authenticate] || false + @auth_options = options[:auth_options] || {} + @auth_strategy = @auth_options[:auth_strategy] || :token + @auth_exempt_paths = @auth_options[:auth_exempt_paths] || [] end def call(env) request = Rack::Request.new(env) - if auth_enabled? && !exempt_from_auth?(request.path) - auth_header = request.env["HTTP_#{@auth_header_name.upcase.gsub('-', '_')}"] - token = auth_header&.gsub('Bearer ', '') + return super if auth_disabled? || exempt_from_auth?(request.path) - return unauthorized_response(request) unless valid_token?(token) + if authenticate_request(request) + super + else + unauthorized_response(request) end - - super end private - def auth_enabled? - @auth_enabled + def auth_disabled? + !@auth_enabled end def exempt_from_auth?(path) @auth_exempt_paths.any? { |exempt_path| path.start_with?(exempt_path) } end - def valid_token?(token) - token == @auth_token + def authenticate_request(request) + case @auth_strategy + when :proc, Proc + authenticate_proc(request) + when :http_basic + authenticate_http_basic(request) + else + authenticate_token(request) + end + end + + def authenticate_http_basic(request) + auth = Rack::Auth::Basic::Request.new(request.env) + user = @auth_options[:auth_user] || ENV.fetch('MCP_AUTH_USER') + password = @auth_options[:auth_password] || ENV.fetch('MCP_AUTH_PASSWORD') + auth.provided? && auth.credentials == [user, password] + end + + def authenticate_token(request) + auth_token = @auth_options[:auth_token] || ENV.fetch('MCP_AUTH_TOKEN') + header_token = request.get_header("HTTP_#{header_name}") + header_token&.gsub(/^Bearer\s+/i, '') == auth_token + end + + def header_name + header = @auth_options[:auth_header] || ENV.fetch('MCP_AUTH_HEADER', 'Authorization') + header.gsub('^HTTP_', '').upcase.gsub('-', '_') + end + + def authenticate_proc(request) + auth_proc = @auth_strategy.is_a?(Proc) ? @auth_strategy : @auth_options[:auth_proc] + auth_proc.call(request) end def unauthorized_response(request) + headers = { 'Content-Type' => 'application/json' } + + headers['WWW-Authenticate'] = 'Basic realm="Fast MCP API"' if @auth_strategy == :http_basic + body = JSON.generate( { jsonrpc: '2.0', error: { code: -32_000, - message: 'Unauthorized: Invalid or missing authentication token' + message: 'Unauthorized: Invalid or missing authentication credentials' }, id: extract_request_id(request) } ) - [401, { 'Content-Type' => 'application/json' }, [body]] + [401, headers, [body]] end def extract_request_id(request) diff --git a/spec/mcp/transports/authenticated_rack_transport_spec.rb b/spec/mcp/transports/authenticated_rack_transport_spec.rb index 928a8e3..c6221ab 100644 --- a/spec/mcp/transports/authenticated_rack_transport_spec.rb +++ b/spec/mcp/transports/authenticated_rack_transport_spec.rb @@ -5,36 +5,60 @@ let(:app) { ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['OK']] } } let(:logger) { Logger.new(nil) } let(:auth_token) { 'valid-token-123' } - let(:auth_header_name) { 'Authorization' } + let(:authenticate) { true } + let(:auth_strategy) { :token } + let(:auth_header) { 'Authorization' } let(:auth_exempt_paths) { ['/public', '/mcp/health'] } + let(:auth_options) do + { + auth_strategy: auth_strategy, + auth_header: auth_header, + auth_token: auth_token, + auth_exempt_paths: auth_exempt_paths, + } + end let(:transport) do described_class.new( app, server, logger: logger, - auth_token: auth_token, - auth_header_name: auth_header_name, - auth_exempt_paths: auth_exempt_paths + authenticate: true, + auth_options: auth_options, ) end + around do |ex| + env_variables = %w[MCP_AUTH_HEADER MCP_AUTH_TOKEN MCP_AUTH_USER MCP_AUTH_PASSWORD] + original = env_variables.map { |k| [k, ENV[k]] }.to_h + + ex.run + + original.each { |k, v| ENV[k] = v } + end + describe '#initialize' do it 'initializes with authentication options' do - expect(transport.instance_variable_get(:@auth_token)).to eq(auth_token) - expect(transport.instance_variable_get(:@auth_header_name)).to eq(auth_header_name) + expect(transport.instance_variable_get(:@auth_enabled)).to be(true) + expect(transport.instance_variable_get(:@auth_strategy)).to eq(:token) + expect(transport.instance_variable_get(:@auth_options)).to eq(auth_options) expect(transport.instance_variable_get(:@auth_exempt_paths)).to eq(auth_exempt_paths) expect(transport.instance_variable_get(:@auth_enabled)).to be(true) end + it 'sets default authentication strategy to token' do + auth_transport = described_class.new(app, server, authenticate: true, logger: logger) + expect(auth_transport.instance_variable_get(:@auth_strategy)).to eq(:token) + end + it 'disables authentication when no token is provided' do no_auth_transport = described_class.new(server, app, logger: logger) expect(no_auth_transport.instance_variable_get(:@auth_enabled)).to be(false) end - it 'uses default header name when not specified' do - custom_transport = described_class.new(server, app, auth_token: auth_token, logger: logger) - expect(custom_transport.instance_variable_get(:@auth_header_name)).to eq('Authorization') + it 'enables authentication when explicitly set to true' do + explicit_auth_transport = described_class.new(app, server, authenticate: true, logger: logger) + expect(explicit_auth_transport.instance_variable_get(:@auth_enabled)).to be(true) end it 'uses default empty array for exempt paths when not specified' do @@ -44,7 +68,7 @@ end describe '#call' do - context 'with valid authentication' do + context 'with valid token authentication' do it 'passes the request to parent when token is valid for non-MCP paths' do env = { 'PATH_INFO' => '/not-mcp', @@ -56,6 +80,19 @@ expect(result).to eq([200, {}, ['OK']]) end + it 'defaults the authentication header to Authorization with ENV token fetching' do + env = { + 'PATH_INFO' => '/not-mcp', + 'HTTP_AUTHORIZATION' => "Bearer #{auth_token}" + } + default_transport = described_class.new(app, server, authenticate: true, logger: logger) + ENV['MCP_AUTH_TOKEN'] = auth_token + + expect(app).to receive(:call).with(env).and_return([200, {}, ['OK']]) + result = default_transport.call(env) + expect(result).to eq([200, {}, ['OK']]) + end + it 'passes MCP path requests to parent class when authentication succeeds' do json_message = '{"jsonrpc":"2.0","method":"test","id":1}' env = { @@ -183,7 +220,7 @@ end context 'with custom header name' do - let(:auth_header_name) { 'X-Api-Key' } + let(:auth_header) { 'X-Api-Key' } it 'accepts token from custom header' do env = { @@ -214,8 +251,11 @@ app, server, logger: logger, - auth_token: auth_token, - auth_header_name: 'X-Custom-Auth' + authenticate: true, + auth_options: { + auth_token: auth_token, + auth_header: 'X-Custom-Auth' + } ) env = { @@ -233,8 +273,11 @@ app, server, logger: logger, - auth_token: auth_token, - auth_header_name: 'X-Custom-Auth-Token' + authenticate: true, + auth_options: { + auth_token: auth_token, + auth_header: 'X-Custom-Auth-Token' + } ) env = { @@ -300,66 +343,142 @@ app, server, logger: logger, - auth_token: auth_token, - allowed_origins: allowed_origins + allowed_origins: allowed_origins, + authenticate: true, + auth_options: { + auth_token: auth_token + } ) end - + it 'accepts requests with allowed origin when authenticated' do - env = { + env = { 'PATH_INFO' => '/mcp/messages', 'REQUEST_METHOD' => 'POST', 'HTTP_ORIGIN' => 'http://localhost', 'HTTP_AUTHORIZATION' => "Bearer #{auth_token}", 'rack.input' => StringIO.new('{"jsonrpc":"2.0","method":"ping","id":1}') } - + expect(server).to receive(:transport=).with(transport) expect(server).to receive(:handle_json_request) .with('{"jsonrpc":"2.0","method":"ping","id":1}') .and_return('{"jsonrpc":"2.0","result":{},"id":1}') - + result = transport.call(env) expect(result[0]).to eq(200) end - + it 'rejects requests with disallowed origin when authenticated' do - env = { + env = { 'PATH_INFO' => '/mcp/messages', 'REQUEST_METHOD' => 'POST', 'HTTP_ORIGIN' => 'http://evil-site.com', 'HTTP_AUTHORIZATION' => "Bearer #{auth_token}", 'rack.input' => StringIO.new('{"jsonrpc":"2.0","method":"ping","id":1}') } - + expect(server).to receive(:transport=).with(transport) # The server should NOT receive handle_json_request for a disallowed origin expect(server).not_to receive(:handle_json_request) - + result = transport.call(env) expect(result[0]).to eq(403) expect(result[1]['Content-Type']).to eq('application/json') - + response = JSON.parse(result[2].first) expect(response['jsonrpc']).to eq('2.0') expect(response['error']['code']).to eq(-32_600) expect(response['error']['message']).to include('Origin validation failed') end - + it 'checks authentication before validating origin' do - env = { + env = { 'PATH_INFO' => '/mcp/messages', 'REQUEST_METHOD' => 'POST', 'HTTP_ORIGIN' => 'http://evil-site.com', 'HTTP_AUTHORIZATION' => 'Bearer invalid-token', 'rack.input' => StringIO.new('{"jsonrpc":"2.0","method":"ping","id":1}') } - + # Should not reach the origin validation since auth fails first result = transport.call(env) expect(result[0]).to eq(401) # Unauthorized, not 403 Forbidden - + + response = JSON.parse(result[2].first) + expect(response['error']['message']).to include('Unauthorized') + end + end + + context 'with custom proc authentication strategy' do + let(:auth_strategy) do + proc { |request| request.get_header('HTTP_CUSTOM_AUTH') == 'valid' } + end + let(:transport) do + described_class.new( + app, + server, + logger: logger, + authenticate: true, + auth_options: { + auth_strategy: auth_strategy + } + ) + end + + it 'authenticates using the custom proc' do + env = { + 'PATH_INFO' => '/not-mcp', + 'HTTP_CUSTOM_AUTH' => 'valid' + } + + expect(app).to receive(:call).with(env).and_return([200, {}, ['OK']]) + result = transport.call(env) + expect(result).to eq([200, {}, ['OK']]) + end + end + + context 'with http_basic authentication strategy' do + let(:transport) do + described_class.new( + app, + server, + logger: logger, + authenticate: true, + auth_options: { + auth_strategy: :http_basic, + auth_user: 'admin', + auth_password: 'password' + } + ) + end + + it 'authenticates using HTTP Basic Auth' do + authorization = "Basic #{Base64.strict_encode64('admin:password')}" + env = { + 'PATH_INFO' => '/not-mcp', + 'HTTP_AUTHORIZATION' => authorization + } + + expect(app).to receive(:call).with(env).and_return([200, {}, ['OK']]) + result = transport.call(env) + expect(result).to eq([200, {}, ['OK']]) + end + + it 'rejects requests with invalid HTTP Basic Auth' do + invalid = "Basic #{Base64.strict_encode64('admin:wrongpassword')}" + env = { + 'PATH_INFO' => '/not-mcp', + 'HTTP_AUTHORIZATION' => invalid + } + + result = transport.call(env) + expect(result[0]).to eq(401) + expect(result[1]['Content-Type']).to eq('application/json') + response = JSON.parse(result[2].first) + expect(response['jsonrpc']).to eq('2.0') + expect(response['error']['code']).to eq(-32_000) expect(response['error']['message']).to include('Unauthorized') end end