Merge pull request #559 from koic/let_providers_without_a_cimd_url_refresh
Let providers without a CIMD URL reader refresh their tokens
This commit is contained in:
commit
e1105c7094
2 changed files with 227 additions and 5 deletions
|
|
@ -557,6 +557,138 @@ module MCP
|
|||
assert_not_requested(:post, "#{@auth_base}/token")
|
||||
end
|
||||
|
||||
def test_refresh_uses_the_stored_credentials_of_a_cross_app_access_provider
|
||||
# RFC 7521 Section 4.1 makes a refresh token unusual for an assertion grant, not forbidden;
|
||||
# when the authorization server issued one, it is exchanged with the stored client secret
|
||||
# and no new ID-JAG assertion is minted.
|
||||
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-at", "refresh_token" => "saved-rt", "issuer" => @auth_base)
|
||||
|
||||
result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||
|
||||
assert_equal(:refreshed, result)
|
||||
assert_equal("test-token-from-flow", provider.access_token)
|
||||
assert_equal(@auth_base, provider.tokens["issuer"])
|
||||
assert_equal(0, assertion_calls)
|
||||
assert_requested(:post, "#{@auth_base}/token") do |req|
|
||||
form = URI.decode_www_form(req.body).to_h
|
||||
|
||||
form["grant_type"] == "refresh_token" &&
|
||||
form["refresh_token"] == "saved-rt" &&
|
||||
!form.key?("assertion") &&
|
||||
req.headers["Authorization"] == "Basic " + Base64.strict_encode64("xaa-client:xaa-secret")
|
||||
end
|
||||
end
|
||||
|
||||
def test_refresh_uses_the_stored_credentials_of_a_client_credentials_provider
|
||||
# RFC 6749 Section 4.4.3 says a refresh token SHOULD NOT be issued for this grant,
|
||||
# so one in storage is unusual, but it must be exchanged rather than crash the refresh.
|
||||
provider = client_credentials_provider
|
||||
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base)
|
||||
|
||||
result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||
|
||||
assert_equal(:refreshed, result)
|
||||
assert_equal(@auth_base, provider.tokens["issuer"])
|
||||
assert_requested(:post, "#{@auth_base}/token") do |req|
|
||||
form = URI.decode_www_form(req.body).to_h
|
||||
|
||||
form["grant_type"] == "refresh_token" &&
|
||||
form["refresh_token"] == "saved-rt" &&
|
||||
req.headers["Authorization"] == "Basic " + Base64.strict_encode64("cc-client:cc-secret")
|
||||
end
|
||||
end
|
||||
|
||||
def test_refresh_refuses_an_authorization_server_that_did_not_issue_a_cross_app_access_token
|
||||
# The token-level issuer binding is what protects these providers: their stored `client_information` carries no `issuer`,
|
||||
# so `ensure_refreshable_client_information!` has nothing to compare.
|
||||
provider = CrossAppAccessProvider.new(
|
||||
client_id: "xaa-client",
|
||||
client_secret: "xaa-secret",
|
||||
assertion_provider: ->(**) { "id-jag-assertion" },
|
||||
)
|
||||
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => "https://old-as.example.com")
|
||||
|
||||
error = assert_raises(Flow::AuthorizationError) do
|
||||
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||
end
|
||||
|
||||
assert_match(/issued by a different authorization server/, error.message)
|
||||
assert_not_requested(:post, "#{@auth_base}/token")
|
||||
assert_equal("saved-rt", provider.tokens["refresh_token"])
|
||||
end
|
||||
|
||||
def test_refresh_refuses_tokens_without_a_recorded_issuer_for_the_client_credentials_and_jwt_bearer_providers
|
||||
# These providers had no refresh before their tokens recorded the issuer, so nothing older needs
|
||||
# tolerating; without the record a refresh would go to whatever server discovery names, unasked.
|
||||
cross_app = CrossAppAccessProvider.new(
|
||||
client_id: "xaa-client",
|
||||
client_secret: "xaa-secret",
|
||||
assertion_provider: ->(**) { "id-jag-assertion" },
|
||||
)
|
||||
[cross_app, client_credentials_provider].each do |provider|
|
||||
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
|
||||
|
||||
error = assert_raises(Flow::AuthorizationError, "should refuse #{provider.class}") do
|
||||
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||
end
|
||||
|
||||
assert_match(/record no issuer/, error.message)
|
||||
assert_equal("saved-rt", provider.tokens["refresh_token"])
|
||||
end
|
||||
|
||||
assert_not_requested(:get, @prm_url)
|
||||
assert_not_requested(:post, "#{@auth_base}/token")
|
||||
end
|
||||
|
||||
def test_refresh_raises_cleanly_for_a_provider_with_neither_client_information_nor_a_cimd_url
|
||||
provider = CrossAppAccessProvider.new(
|
||||
client_id: "xaa-client",
|
||||
client_secret: "xaa-secret",
|
||||
assertion_provider: ->(**) { "id-jag-assertion" },
|
||||
)
|
||||
provider.storage.save_client_information(nil)
|
||||
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base)
|
||||
|
||||
error = assert_raises(Flow::AuthorizationError) do
|
||||
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||
end
|
||||
|
||||
assert_match(/no client_information/, error.message)
|
||||
assert_not_requested(:post, "#{@auth_base}/token")
|
||||
end
|
||||
|
||||
def test_run_registers_through_dcr_for_a_provider_without_a_cimd_url_reader
|
||||
# Registration reads the CIMD URL through the same duck-typed helper as refresh,
|
||||
# so a provider that lacks the reader goes to Dynamic Client Registration.
|
||||
provider_class = Class.new(Provider) { undef_method :client_id_metadata_document_url }
|
||||
state_value = nil
|
||||
provider = provider_class.new(
|
||||
client_metadata: {
|
||||
redirect_uris: ["http://localhost:0/callback"],
|
||||
grant_types: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
},
|
||||
redirect_uri: "http://localhost:0/callback",
|
||||
redirect_handler: ->(url) { state_value = URI.decode_www_form(url.query).to_h.fetch("state") },
|
||||
callback_handler: -> { ["test-auth-code", state_value] },
|
||||
)
|
||||
|
||||
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||
|
||||
assert_equal(:authorized, result)
|
||||
assert_requested(:post, "#{@auth_base}/register")
|
||||
end
|
||||
|
||||
def test_run_uses_authorization_code_grant_for_default_provider
|
||||
# A standard `Provider` declares `authorization_flow == :authorization_code`,
|
||||
# so `Flow` runs the interactive grant regardless of what `client_metadata[:grant_types]` happens to list.
|
||||
|
|
|
|||
|
|
@ -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