Let providers without a CIMD URL reader refresh their tokens
## Motivation and Context `Flow#refresh!` reads `client_id_metadata_document_url` from the provider unconditionally, once to decide whether a provider without stored `client_information` may still refresh and once to build the CIMD `client_id`. Only `Provider` defines that reader: `CrossAppAccessProvider` and `ClientCredentialsProvider` raise `NoMethodError` once a stored `refresh_token` is found. `MCP::Client::HTTP` calls `refresh!` on any `401` while a `refresh_token` is stored, so a `jwt-bearer` client whose authorization server issued one fails with `NoMethodError` on the first expired token instead of refreshing. The error is not an `AuthorizationError`, so the transport does not fall back to the grant either. RFC 7521 Section 4.1 makes a refresh token unusual for an assertion grant, not forbidden, and one in storage must not crash the client. The flow now reads the URL through a duck-typed helper, like `authorization_flow` and `client_assertion`, so a provider without the reader has no CIMD URL and refreshes with its stored `client_information`: the `refresh_token` grant is sent with the stored client authentication, and no new ID-JAG assertion is obtained. The authorization-code registration path reads the URL through the same helper. The docs now say that a refresh token issued anyway is used on the next `401`, and that the assertion provider is not called again on refresh. A refresh token stored without the `issuer` the SDK records is refused for these two providers before any request: unlike `Provider`, they have no tokens that predate the record, and a refresh asks no validator, so such a token would be presented to whatever authorization server discovery names. The refusal is an `AuthorizationError`, which sends the transport back through the grant, and the grant asks. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb` refresh through a `CrossAppAccessProvider` and a `ClientCredentialsProvider` holding a `refresh_token`, asserting the `refresh_token` grant, the Basic credentials, and that the assertion provider is not called. A transport test in `test/mcp/client/oauth/http_oauth_test.rb` sends a `401` to a `CrossAppAccessProvider` with a stored `refresh_token` and expects the refreshed bearer token on the retry. All three raise `NoMethodError` against the previous library. Further tests pin the issuer stamp on the refreshed tokens, the refusal of an authorization server that did not issue them for a `CrossAppAccessProvider`, a clean `AuthorizationError` for a provider with neither the reader nor stored `client_information`, and registration through DCR for a provider without the reader. A refresh token without a recorded issuer is refused for both providers before any request is sent. ## Breaking Changes None.
This commit is contained in:
parent
f22ce86e97
commit
f51ccfbe66
1 changed files with 227 additions and 5 deletions
|
|
@ -627,6 +627,68 @@ module MCP
|
|||
assert_equal("refreshed-token", provider.access_token)
|
||||
end
|
||||
|
||||
def test_send_request_refreshes_for_a_cross_app_access_provider_holding_a_refresh_token
|
||||
# Refresh serves every provider, not only the authorization-code one that has a CIMD URL reader.
|
||||
stub_request(:post, @mcp_url).with { |req|
|
||||
req.headers["Authorization"] != "Bearer refreshed-token"
|
||||
}.to_return(
|
||||
status: 401,
|
||||
headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") },
|
||||
body: "",
|
||||
)
|
||||
|
||||
stub_request(:post, @mcp_url).with(
|
||||
headers: { "Authorization" => "Bearer refreshed-token" }
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
||||
)
|
||||
|
||||
stub_request(:get, @prm_url).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
|
||||
)
|
||||
|
||||
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: JSON.generate(
|
||||
issuer: @auth_base,
|
||||
token_endpoint: "#{@auth_base}/token",
|
||||
grant_types_supported: ["urn:ietf:params:oauth:grant-type:jwt-bearer", "refresh_token"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_basic"],
|
||||
),
|
||||
)
|
||||
|
||||
stub_request(:post, "#{@auth_base}/token").with(
|
||||
body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt")
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: JSON.generate(access_token: "refreshed-token", token_type: "Bearer", expires_in: 3600),
|
||||
)
|
||||
|
||||
assertion_calls = 0
|
||||
provider = CrossAppAccessProvider.new(
|
||||
client_id: "xaa-client",
|
||||
client_secret: "xaa-secret",
|
||||
assertion_provider: ->(**) {
|
||||
assertion_calls += 1
|
||||
"id-jag-assertion"
|
||||
},
|
||||
)
|
||||
provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt", "issuer" => @auth_base)
|
||||
|
||||
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
||||
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
||||
|
||||
assert_equal({ "ok" => true }, response["result"])
|
||||
assert_equal("refreshed-token", provider.access_token)
|
||||
assert_equal(0, assertion_calls)
|
||||
end
|
||||
|
||||
def test_send_request_surfaces_a_bad_token_request_params_hook_instead_of_reauthorizing
|
||||
# A provider whose `token_request_params` names a reserved key is misconfigured, not unauthorized:
|
||||
# the refresh attempt must raise rather than fall through to the interactive flow,
|
||||
|
|
|
|||
Loading…
Reference in a new issue