Skip to content

Commit 67455a0

Browse files
authored
Merge pull request #119 from patvice/token-manager-raise-oauth-error-response
Align token error handling and tests with RFC 6749 section 5.2
2 parents e454768 + 28c0e28 commit 67455a0

2 files changed

Lines changed: 211 additions & 3 deletions

File tree

lib/ruby_llm/mcp/auth/token_manager.rb

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,17 @@ def refresh_token(server_metadata, client_info, token, server_url)
7171

7272
# Return nil on error responses
7373
return nil if response.is_a?(HTTPX::ErrorResponse)
74-
return nil unless response.status == 200
74+
75+
if response.status != 200
76+
oauth_error = extract_oauth_error(response.body.to_s)
77+
raise_oauth_error!("Token refresh", oauth_error, response.status) if oauth_error
78+
return nil
79+
end
7580

7681
parse_refresh_response(response, token)
82+
rescue Errors::TransportError => e
83+
logger.warn(e.message)
84+
nil
7785
rescue JSON::ParserError => e
7886
logger.warn("Invalid token refresh response: #{e.message}")
7987
nil
@@ -194,6 +202,9 @@ def validate_token_response!(response, context)
194202
raise Errors::TransportError.new(message: "#{context} failed: #{error_message}")
195203
end
196204

205+
oauth_error = extract_oauth_error(response.body.to_s)
206+
raise_oauth_error!(context, oauth_error, response.status) if oauth_error
207+
197208
return if response.status == 200
198209

199210
raise Errors::TransportError.new(
@@ -207,8 +218,18 @@ def validate_token_response!(response, context)
207218
# @return [Token] parsed token
208219
def parse_token_response(response)
209220
data = JSON.parse(response.body.to_s)
221+
raise_oauth_error!("Token exchange", extract_oauth_error(data), response.status)
222+
223+
access_token = data["access_token"]
224+
if access_token.nil? || access_token.empty?
225+
raise Errors::TransportError.new(
226+
message: "Token exchange failed: invalid token response (missing access_token)",
227+
code: response.status
228+
)
229+
end
230+
210231
Token.new(
211-
access_token: data["access_token"],
232+
access_token: access_token,
212233
token_type: data["token_type"] || "Bearer",
213234
expires_in: data["expires_in"],
214235
scope: data["scope"],
@@ -222,14 +243,64 @@ def parse_token_response(response)
222243
# @return [Token] new token
223244
def parse_refresh_response(response, old_token)
224245
data = JSON.parse(response.body.to_s)
246+
raise_oauth_error!("Token refresh", extract_oauth_error(data), response.status)
247+
248+
access_token = data["access_token"]
249+
if access_token.nil? || access_token.empty?
250+
raise Errors::TransportError.new(
251+
message: "Token refresh failed: invalid token response (missing access_token)",
252+
code: response.status
253+
)
254+
end
255+
225256
Token.new(
226-
access_token: data["access_token"],
257+
access_token: access_token,
227258
token_type: data["token_type"] || "Bearer",
228259
expires_in: data["expires_in"],
229260
scope: data["scope"],
230261
refresh_token: data["refresh_token"] || old_token.refresh_token
231262
)
232263
end
264+
265+
# Extract OAuth error fields from JSON response data
266+
# @param source [String, Hash] response body string or parsed JSON hash
267+
# @return [Hash, nil] OAuth error fields or nil
268+
def extract_oauth_error(source)
269+
data = source.is_a?(Hash) ? source : JSON.parse(source)
270+
error = data["error"] || data[:error]
271+
return nil unless error
272+
273+
{
274+
error: error,
275+
error_description: data["error_description"] || data[:error_description],
276+
error_uri: data["error_uri"] || data[:error_uri]
277+
}
278+
rescue JSON::ParserError
279+
nil
280+
end
281+
282+
# Raise TransportError for OAuth error responses
283+
# @param context [String] context for the error
284+
# @param oauth_error [Hash, nil] OAuth error fields
285+
# @param status_code [Integer, nil] HTTP response status code
286+
# @raise [Errors::TransportError] when oauth_error is present
287+
def raise_oauth_error!(context, oauth_error, status_code)
288+
return unless oauth_error
289+
290+
error = oauth_error[:error]
291+
description = oauth_error[:error_description]
292+
error_uri = oauth_error[:error_uri]
293+
294+
message = "#{context} failed: OAuth error '#{error}'"
295+
message += ": #{description}" if description
296+
message += " (#{error_uri})" if error_uri
297+
298+
raise Errors::TransportError.new(
299+
message: message,
300+
code: status_code,
301+
error: error
302+
)
303+
end
233304
end
234305
end
235306
end

spec/ruby_llm/mcp/auth/token_manager_spec.rb

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,97 @@
174174
expect(options[:form][:scope]).to eq(scope)
175175
end
176176
end
177+
178+
context "when token endpoint returns OAuth error response" do
179+
let(:oauth_error_response) do
180+
{
181+
"error" => "invalid_request",
182+
"error_description" => "Missing required parameter: grant_type",
183+
"error_uri" => "https://example.com/docs/oauth-errors#invalid_request"
184+
}
185+
end
186+
187+
before do
188+
response = instance_double(HTTPX::Response, status: 400, body: oauth_error_response.to_json)
189+
allow(http_client).to receive(:post).and_return(response)
190+
end
191+
192+
it "raises TransportError with OAuth error details from RFC 6749 section 5.2 format" do
193+
expect do
194+
manager.exchange_client_credentials(server_metadata, client_info_with_secret, scope, server_url)
195+
end.to raise_error(RubyLLM::MCP::Errors::TransportError) { |error|
196+
expect(error.message).to include("OAuth error 'invalid_request'")
197+
expect(error.message).to include("Missing required parameter: grant_type")
198+
expect(error.code).to eq(400)
199+
expect(error.error).to eq("invalid_request")
200+
}
201+
end
202+
end
203+
204+
context "when token endpoint returns 401 invalid_client error response" do
205+
let(:oauth_error_response) do
206+
{
207+
"error" => "invalid_client",
208+
"error_description" => "Client authentication failed"
209+
}
210+
end
211+
212+
before do
213+
response = instance_double(HTTPX::Response, status: 401, body: oauth_error_response.to_json)
214+
allow(http_client).to receive(:post).and_return(response)
215+
end
216+
217+
it "raises TransportError with RFC 6749 invalid_client semantics" do
218+
expect do
219+
manager.exchange_client_credentials(server_metadata, client_info_with_secret, scope, server_url)
220+
end.to raise_error(RubyLLM::MCP::Errors::TransportError) { |error|
221+
expect(error.message).to include("OAuth error 'invalid_client'")
222+
expect(error.message).to include("Client authentication failed")
223+
expect(error.code).to eq(401)
224+
expect(error.error).to eq("invalid_client")
225+
}
226+
end
227+
end
228+
229+
context "when token endpoint returns HTTP 200 with OAuth error payload" do
230+
let(:oauth_error_response) do
231+
{
232+
"error" => "invalid_client",
233+
"error_description" => "Client authentication failed."
234+
}
235+
end
236+
237+
before do
238+
response = instance_double(HTTPX::Response, status: 200, body: oauth_error_response.to_json)
239+
allow(http_client).to receive(:post).and_return(response)
240+
end
241+
242+
it "raises TransportError instead of creating a token with missing access_token" do
243+
expect do
244+
manager.exchange_client_credentials(server_metadata, client_info_with_secret, scope, server_url)
245+
end.to raise_error(RubyLLM::MCP::Errors::TransportError, /OAuth error 'invalid_client'/)
246+
end
247+
end
248+
249+
context "when token endpoint returns success status but no access token" do
250+
let(:incomplete_response) do
251+
{
252+
"token_type" => "Bearer",
253+
"expires_in" => 3600
254+
}
255+
end
256+
257+
before do
258+
response = instance_double(HTTPX::Response, status: 200, body: incomplete_response.to_json)
259+
allow(http_client).to receive(:post).and_return(response)
260+
end
261+
262+
it "raises a clear TransportError for invalid token payload" do
263+
expect do
264+
manager.exchange_client_credentials(server_metadata, client_info_with_secret, scope, server_url)
265+
end.to raise_error(RubyLLM::MCP::Errors::TransportError, /missing access_token/)
266+
end
267+
end
177268
end
178269

179270
describe "#refresh_token" do
@@ -265,5 +356,51 @@
265356
expect(logger).to have_received(:warn).with(/Invalid token refresh response/)
266357
end
267358
end
359+
360+
context "when refresh response contains OAuth error fields" do
361+
let(:oauth_error_response) do
362+
{
363+
"error" => "invalid_grant",
364+
"error_description" => "Refresh token is expired"
365+
}
366+
end
367+
368+
before do
369+
response = instance_double(HTTPX::Response, status: 200, body: oauth_error_response.to_json)
370+
allow(http_client).to receive(:post).and_return(response)
371+
end
372+
373+
it "returns nil and logs warning" do
374+
result = manager.refresh_token(server_metadata, client_info, token, server_url)
375+
376+
expect(result).to be_nil
377+
expect(logger).to have_received(:warn).with(
378+
/Token refresh failed: OAuth error 'invalid_grant'/
379+
)
380+
end
381+
end
382+
383+
context "when refresh endpoint returns non-200 with OAuth error payload" do
384+
let(:oauth_error_response) do
385+
{
386+
"error" => "invalid_grant",
387+
"error_description" => "Refresh token is expired"
388+
}
389+
end
390+
391+
before do
392+
response = instance_double(HTTPX::Response, status: 400, body: oauth_error_response.to_json)
393+
allow(http_client).to receive(:post).and_return(response)
394+
end
395+
396+
it "returns nil and logs OAuth error details" do
397+
result = manager.refresh_token(server_metadata, client_info, token, server_url)
398+
399+
expect(result).to be_nil
400+
expect(logger).to have_received(:warn).with(
401+
/Token refresh failed: OAuth error 'invalid_grant': Refresh token is expired/
402+
)
403+
end
404+
end
268405
end
269406
end

0 commit comments

Comments
 (0)