From b814f2dbba712f937d0074dd93497132359f4426 Mon Sep 17 00:00:00 2001 From: Edgars Beigarts Date: Mon, 12 Oct 2026 12:21:30 +0300 Subject: [PATCH] Accept HTTP Basic client authentication at the token endpoint The token endpoint only read client_id and client_secret from the request body, and the server metadata did not list token_endpoint_auth_methods_supported. Clients then default to client_secret_basic (RFC 8414), which this server rejected with invalid_client. RubyLLM 2.1 does exactly that, so connecting from the AI platform failed. Accept HTTP Basic credentials (RFC 6749 section 2.3.1) alongside the request body, and advertise both methods. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/mail_mcp/app.rb | 16 +++++++++++++--- spec/mail_mcp/app_spec.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/mail_mcp/app.rb b/lib/mail_mcp/app.rb index e827fbd..8eeaf9e 100644 --- a/lib/mail_mcp/app.rb +++ b/lib/mail_mcp/app.rb @@ -64,6 +64,7 @@ class App < Sinatra::Base token_endpoint: "#{base_url}/oauth/token", response_types_supported: ["code"], grant_types_supported: ["authorization_code"], + token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post], code_challenge_methods_supported: ["S256"] }) end @@ -106,9 +107,8 @@ class App < Sinatra::Base post "/oauth/token" do content_type :json - grant_type = params[:grant_type] - client_id = params[:client_id] - client_secret = params[:client_secret] + grant_type = params[:grant_type] + client_id, client_secret = client_credentials client_config = begin JwtService.decode_client_id(client_id.to_s) @@ -146,6 +146,16 @@ class App < Sinatra::Base private + # RFC 6749 section 2.3.1: clients may authenticate with HTTP Basic, whose + # user and password are the form-encoded client_id and client_secret, or + # with both in the request body. + def client_credentials + auth = Rack::Auth::Basic::Request.new(request.env) + return [params[:client_id], params[:client_secret]] unless auth.provided? && auth.basic? + + auth.credentials.map { |part| URI.decode_www_form_component(part) } + end + def resolve_mcp_context auth = request.env["HTTP_AUTHORIZATION"] return [nil, unauthorized_response("missing bearer token")] unless auth&.start_with?("Bearer ") diff --git a/spec/mail_mcp/app_spec.rb b/spec/mail_mcp/app_spec.rb index fe0fe85..9abfdb9 100644 --- a/spec/mail_mcp/app_spec.rb +++ b/spec/mail_mcp/app_spec.rb @@ -35,6 +35,7 @@ def app expect(body["issuer"]).to eq(ENV.fetch("BASE_URL", nil)) expect(body["code_challenge_methods_supported"]).to include("S256") expect(body).not_to have_key("registration_endpoint") + expect(body["token_endpoint_auth_methods_supported"]).to eq(%w[client_secret_basic client_secret_post]) end end @@ -97,6 +98,32 @@ def issue_code expect(body["token_type"]).to eq("Bearer") end + it "accepts client credentials sent with HTTP Basic authentication" do + basic_authorize URI.encode_www_form_component(client_id), URI.encode_www_form_component(client_secret) + post "/oauth/token", { + grant_type: "authorization_code", + code: issue_code, + code_verifier: verifier, + redirect_uri: redirect_uri, + client_id: client_id + } + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["access_token"]).not_to be_nil + end + + it "returns invalid_client for a wrong client_secret sent with HTTP Basic authentication" do + basic_authorize URI.encode_www_form_component(client_id), "wrong-secret" + post "/oauth/token", { + grant_type: "authorization_code", + code: issue_code, + code_verifier: verifier, + redirect_uri: redirect_uri, + client_id: client_id + } + expect(last_response.status).to eq(401) + expect(JSON.parse(last_response.body)["error"]).to eq("invalid_client") + end + it "issues a new access_token via refresh_token grant" do post "/oauth/token", { grant_type: "authorization_code",