Skip to content

Commit 7d7a566

Browse files
authored
Merge pull request #549 from junyuanz1/fix/oauth-token-error-diagnostics
fix(client/oauth): preserve token endpoint error diagnostics
2 parents a18c628 + 8d1d5be commit 7d7a566

3 files changed

Lines changed: 218 additions & 17 deletions

File tree

‎docs/_client/authorization.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,27 @@ provider = MCP::Client::OAuth::Provider.new(
159159
)
160160
```
161161

162+
### Token Endpoint Errors
163+
164+
When a token exchange or refresh fails, `MCP::Client::OAuth::Flow::AuthorizationError` includes the HTTP status and
165+
the authorization server's `error` and `error_description` from [RFC 6749 Section 5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2).
166+
For example:
167+
168+
```text
169+
Token endpoint returned status 400. invalid_request: Client must not use multiple authentication methods
170+
```
171+
172+
The exception exposes `http_status`, `error`, and `error_description` readers for structured diagnostics. Missing or non-string
173+
diagnostic fields are `nil`; non-JSON responses retain the status-only message. Other authorization failures have `nil` readers.
174+
An `invalid_grant` response still raises `Flow::InvalidGrantError`, a subclass of `Flow::AuthorizationError`, so refresh-token
175+
recovery behavior is unchanged.
176+
177+
Diagnostic fields are limited to 128 characters for `error` and 512 for `error_description`, including a trailing `...` when
178+
truncated. Characters outside the RFC's printable ASCII set are replaced with spaces, and surrounding whitespace is removed.
179+
The SDK excludes all other response fields, including `error_uri`, and does not include the raw response body in these errors.
180+
Descriptions are provider-controlled text, not guaranteed to be free of sensitive information; apply your application's logging
181+
and redaction policy before persisting them or displaying them to users.
182+
162183
### Client Credentials Grant
163184

164185
For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`.

‎lib/mcp/client/oauth/flow.rb‎

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,19 @@ module OAuth
1414
# `Provider`; this class consumes a Provider plus signal data extracted from
1515
# the failing response (resource_metadata URL, scope challenge).
1616
class Flow
17-
class AuthorizationError < StandardError; end
17+
TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128
18+
TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512
19+
20+
class AuthorizationError < StandardError
21+
attr_reader :http_status, :error, :error_description
22+
23+
def initialize(message = nil, http_status: nil, error: nil, error_description: nil)
24+
super(message)
25+
@http_status = http_status
26+
@error = error
27+
@error_description = error_description
28+
end
29+
end
1830

1931
# Raised specifically when the token endpoint rejects a grant with
2032
# `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to
@@ -1062,11 +1074,7 @@ def post_to_token_endpoint(as_metadata:, client_info:, form:)
10621074
end
10631075

10641076
if response.status < 200 || response.status >= 300
1065-
if token_endpoint_error_code(response) == "invalid_grant"
1066-
raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant."
1067-
end
1068-
1069-
raise AuthorizationError, "Token endpoint returned status #{response.status}."
1077+
raise token_endpoint_error(response)
10701078
end
10711079

10721080
parsed = begin
@@ -1087,17 +1095,35 @@ def post_to_token_endpoint(as_metadata:, client_info:, form:)
10871095
parsed
10881096
end
10891097

1090-
# Extracts the `error` code from an RFC 6749 §5.2 error response body
1091-
# when one is parseable. Returns nil on any parse failure or when
1092-
# the body is not JSON.
1093-
def token_endpoint_error_code(response)
1094-
body = response_body_string(response).to_s
1095-
return if body.empty?
1098+
# Surface only RFC 6749 §5.2 diagnostic fields, never the raw response,
1099+
# which may contain tokens or other credentials. Classify the original
1100+
# code so sanitization cannot turn malformed input into invalid_grant.
1101+
def token_endpoint_error(response)
1102+
message = "Token endpoint returned status #{response.status}."
1103+
error_class = AuthorizationError
1104+
parsed = JSON.parse(response_body_string(response))
1105+
parsed = {} unless parsed.is_a?(Hash)
1106+
1107+
error_class = parsed["error"] == "invalid_grant" ? InvalidGrantError : AuthorizationError
1108+
error = token_endpoint_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
1109+
description = token_endpoint_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)
1110+
message += " #{[error, description].compact.join(": ")}" if error || description
1111+
1112+
error_class.new(message, http_status: response.status, error: error, error_description: description)
1113+
rescue StandardError
1114+
# Diagnostics must not mask the endpoint failure or change refresh recovery.
1115+
error_class.new("Token endpoint returned status #{response.status}.", http_status: response.status)
1116+
end
10961117

1097-
parsed = JSON.parse(body)
1098-
parsed["error"] if parsed.is_a?(Hash)
1099-
rescue JSON::ParserError
1100-
nil
1118+
def token_endpoint_diagnostic(value, limit:)
1119+
return unless value.is_a?(String)
1120+
1121+
# RFC 6749 permits printable ASCII except double quotes and backslashes.
1122+
# Replace other characters to keep provider text on one log line.
1123+
value = value.scrub(" ").gsub(/[^\x20-\x21\x23-\x5B\x5D-\x7E]/, " ").strip
1124+
return if value.empty?
1125+
1126+
value.length > limit ? "#{value[0, limit - 3]}..." : value
11011127
end
11021128

11031129
# Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be

‎test/mcp/client/oauth/flow_test.rb‎

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2731,7 +2731,161 @@ def test_refresh_raises_invalid_grant_error_when_token_endpoint_says_invalid_gra
27312731
provider.save_client_information("client_id" => "test-client")
27322732
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "revoked-rt")
27332733

2734-
assert_raises(Flow::InvalidGrantError) do
2734+
error = assert_raises(Flow::InvalidGrantError) do
2735+
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
2736+
end
2737+
assert_equal(400, error.http_status)
2738+
assert_equal("invalid_grant", error.error)
2739+
assert_equal("refresh token expired", error.error_description)
2740+
assert_equal("Token endpoint returned status 400. invalid_grant: refresh token expired", error.message)
2741+
end
2742+
2743+
def test_token_exchange_preserves_oauth_diagnostics
2744+
stub_request(:post, "#{@auth_base}/token").to_return(
2745+
status: 400,
2746+
body: JSON.generate(
2747+
error: "invalid_request",
2748+
error_description: "Client must not use multiple authentication methods",
2749+
access_token: "do-not-log-access-token",
2750+
refresh_token: "do-not-log-refresh-token",
2751+
client_secret: "do-not-log-client-secret",
2752+
error_uri: "https://auth.example.com/error?secret=do-not-log",
2753+
),
2754+
)
2755+
2756+
error = assert_raises(Flow::AuthorizationError) do
2757+
capture_authorization_scope(grant_types: ["authorization_code"])
2758+
end
2759+
2760+
assert_equal(400, error.http_status)
2761+
assert_equal("invalid_request", error.error)
2762+
assert_equal("Client must not use multiple authentication methods", error.error_description)
2763+
assert_equal(
2764+
"Token endpoint returned status 400. invalid_request: Client must not use multiple authentication methods",
2765+
error.message,
2766+
)
2767+
end
2768+
2769+
def test_refresh_preserves_oauth_diagnostics
2770+
error = refresh_token_endpoint_error(
2771+
JSON.generate(error: "invalid_client", error_description: "Client authentication failed"),
2772+
status: 401,
2773+
)
2774+
2775+
assert_instance_of(Flow::AuthorizationError, error)
2776+
assert_equal(401, error.http_status)
2777+
assert_equal("invalid_client", error.error)
2778+
assert_equal("Client authentication failed", error.error_description)
2779+
assert_equal("Token endpoint returned status 401. invalid_client: Client authentication failed", error.message)
2780+
end
2781+
2782+
def test_token_endpoint_errors_fall_back_for_malformed_bodies
2783+
["", "<html>secret</html>", "{broken", "null", "[]", '"secret"', "42"].each do |body|
2784+
error = refresh_token_endpoint_error(body)
2785+
2786+
assert_instance_of(Flow::AuthorizationError, error)
2787+
assert_equal(400, error.http_status)
2788+
assert_nil(error.error)
2789+
assert_nil(error.error_description)
2790+
assert_equal("Token endpoint returned status 400.", error.message)
2791+
end
2792+
end
2793+
2794+
def test_token_endpoint_errors_ignore_non_string_and_empty_fields
2795+
[nil, 42, true, [], { secret: "hidden" }, "", " \r\n\t"].each do |value|
2796+
error = refresh_token_endpoint_error(JSON.generate(error: value, error_description: value))
2797+
2798+
assert_nil(error.error)
2799+
assert_nil(error.error_description)
2800+
assert_equal("Token endpoint returned status 400.", error.message)
2801+
end
2802+
end
2803+
2804+
def test_token_endpoint_errors_preserve_optional_fields_independently
2805+
error = refresh_token_endpoint_error(JSON.generate(error: "provider_extension"))
2806+
assert_equal("provider_extension", error.error)
2807+
assert_nil(error.error_description)
2808+
assert_equal("Token endpoint returned status 400. provider_extension", error.message)
2809+
2810+
error = refresh_token_endpoint_error(JSON.generate(error_description: "Details without a code"))
2811+
assert_nil(error.error)
2812+
assert_equal("Details without a code", error.error_description)
2813+
assert_equal("Token endpoint returned status 400. Details without a code", error.message)
2814+
end
2815+
2816+
def test_token_endpoint_errors_sanitize_without_changing_grant_classification
2817+
error = refresh_token_endpoint_error(
2818+
JSON.generate(error: "invalid_grant\n", error_description: "expired\r\n\t\e\u0000\"\\\u2028token"),
2819+
)
2820+
2821+
assert_instance_of(Flow::AuthorizationError, error)
2822+
assert_equal("invalid_grant", error.error)
2823+
assert_equal("expired token", error.error_description)
2824+
assert_equal("Token endpoint returned status 400. invalid_grant: expired token", error.message)
2825+
end
2826+
2827+
def test_invalid_grant_with_invalid_utf8_description_preserves_classification
2828+
error = refresh_token_endpoint_error(
2829+
"{\"error\":\"invalid_grant\",\"error_description\":\"bad \xFF byte\"}".b,
2830+
)
2831+
2832+
assert_instance_of(Flow::InvalidGrantError, error)
2833+
assert_equal(400, error.http_status)
2834+
assert_equal("invalid_grant", error.error)
2835+
assert_equal("bad byte", error.error_description)
2836+
assert_equal("Token endpoint returned status 400. invalid_grant: bad byte", error.message)
2837+
end
2838+
2839+
def test_token_endpoint_errors_scrub_invalid_utf8_in_both_fields
2840+
error = refresh_token_endpoint_error(
2841+
"{\"error\":\"invalid_grant\xFF\",\"error_description\":\"bad \xFF byte\"}".b,
2842+
)
2843+
2844+
assert_instance_of(Flow::AuthorizationError, error)
2845+
assert_equal(400, error.http_status)
2846+
assert_equal("invalid_grant", error.error)
2847+
assert_equal("bad byte", error.error_description)
2848+
assert_equal("Token endpoint returned status 400. invalid_grant: bad byte", error.message)
2849+
end
2850+
2851+
def test_token_endpoint_errors_fall_back_when_diagnostic_extraction_raises
2852+
Flow.any_instance.stubs(:token_endpoint_diagnostic).raises(ArgumentError, "sensitive provider text")
2853+
2854+
{ "invalid_grant" => Flow::InvalidGrantError, "invalid_client" => Flow::AuthorizationError }.each do |code, klass|
2855+
error = refresh_token_endpoint_error(JSON.generate(error: code, error_description: "details"))
2856+
2857+
assert_instance_of(klass, error)
2858+
assert_equal(400, error.http_status)
2859+
assert_nil(error.error)
2860+
assert_nil(error.error_description)
2861+
assert_equal("Token endpoint returned status 400.", error.message)
2862+
end
2863+
end
2864+
2865+
def test_token_endpoint_errors_bound_diagnostic_lengths
2866+
error = refresh_token_endpoint_error(JSON.generate(error: "e" * 200, error_description: "d" * 1000))
2867+
2868+
assert_equal("#{"e" * 125}...", error.error)
2869+
assert_equal("#{"d" * 509}...", error.error_description)
2870+
assert_equal("Token endpoint returned status 400. #{error.error}: #{error.error_description}", error.message)
2871+
end
2872+
2873+
def test_other_authorization_errors_have_no_token_endpoint_diagnostics
2874+
error = Flow::AuthorizationError.new("Discovery failed")
2875+
2876+
assert_equal("Discovery failed", error.message)
2877+
assert_nil(error.http_status)
2878+
assert_nil(error.error)
2879+
assert_nil(error.error_description)
2880+
end
2881+
2882+
def refresh_token_endpoint_error(body, status: 400)
2883+
stub_request(:post, "#{@auth_base}/token").to_return(status: status, body: body)
2884+
provider = ssrf_test_provider
2885+
provider.save_client_information("client_id" => "test-client")
2886+
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
2887+
2888+
assert_raises(Flow::AuthorizationError) do
27352889
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
27362890
end
27372891
end

0 commit comments

Comments
 (0)