test/integration/oauth_audience_test.rb
Rama main · réplica de lectura del repositorio original.
require "test_helper"
# Grants for the hosted MCP server (hosted plan, phase B): audienced to it by
# RFC 8707's resource parameter, bound to the one account picked at consent,
# and refused by Fizzy's own API.
class OauthAudienceTest < ActionDispatch::IntegrationTest
MCP_RESOURCE = "https://mcp.example.com/mcp"
CODE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
setup do
@previous_mcp_resources = Rails.application.config.x.oauth.mcp_resources
Rails.application.config.x.oauth.mcp_resources = [ MCP_RESOURCE ]
@client = oauth_clients(:mcp_client)
@david_at_initech = accounts(:initech).users.create!(identity: identities(:david), name: "David", role: :member, verified_at: Time.current)
end
teardown do
Rails.application.config.x.oauth.mcp_resources = @previous_mcp_resources
end
test "consent for the MCP resource asks which account to connect" do
sign_in_as :david
get_consent resource: MCP_RESOURCE
assert_response :success
assert_select "input[type=radio][name=account_id]", 2
assert_select "input[type=radio][name=account_id][value=?]", accounts("37s").id
assert_select "input[type=radio][name=account_id][value=?]", accounts(:initech).id
assert_select "input[type=hidden][name=resource][value=?]", MCP_RESOURCE
end
test "consent without a resource, or naming Fizzy's own API, asks for no account" do
sign_in_as :david
[ nil, "http://www.example.com/", "http://www.example.com" ].each do |resource|
get_consent resource: resource
assert_response :success
assert_select "select[name=account_id]", 0
assert_select "input[name=resource]", 0
end
end
test "an unknown resource is invalid_target" do
sign_in_as :david
[ "https://evil.example.com/mcp", "#{MCP_RESOURCE}/", " " ].each do |resource|
get_consent resource: resource
assert_response :redirect
assert_equal "invalid_target", redirect_params["error"]
end
end
# resource[]= is Rails' list syntax, not RFC 8707's repeated parameter (a
# bare key repeated is invalid_target, below), so it's refused as a malformed
# parameter by the single-valued check, before any audience is read.
test "a resource or account_id sent as a list is invalid_request" do
sign_in_as :david
get_consent resource: [ MCP_RESOURCE, MCP_RESOURCE ]
assert_response :redirect
assert_equal "invalid_request", redirect_params["error"]
assert_match "resource", redirect_params["error_description"]
post_consent resource: MCP_RESOURCE, account_id: [ accounts(:initech).id ]
assert_response :redirect
assert_equal "invalid_request", redirect_params["error"]
assert_match "account_id", redirect_params["error_description"]
assert_nil redirect_params["code"]
end
test "a token request naming resource as a list is invalid_request" do
sign_in_as :david
code = consent_code(resource: MCP_RESOURCE, account_id: accounts(:initech).id)
redeem code, resource: [ MCP_RESOURCE ]
assert_response :bad_request
assert_equal "invalid_request", response.parsed_body["error"]
assert_match "resource", response.parsed_body["error_description"]
end
test "no resource is an MCP resource until one is configured" do
Rails.application.config.x.oauth.mcp_resources = []
sign_in_as :david
get_consent resource: MCP_RESOURCE
assert_response :redirect
assert_equal "invalid_target", redirect_params["error"]
end
test "the account picked at consent and the audience are stamped on the grant and kept through rotation" do
sign_in_as :david
grant = connect(account: accounts(:initech))
assert_equal MCP_RESOURCE, grant.audience
assert_equal accounts(:initech).id, grant.account_id
assert grant.audienced?
assert grant.account_bound?
untenanted do
post oauth_token_path, params: { grant_type: "refresh_token", client_id: @client.client_id, refresh_token: @token_response["refresh_token"] }, as: :json
end
assert_response :success
grant.reload
assert_equal MCP_RESOURCE, grant.audience
assert_equal accounts(:initech).id, grant.account_id
end
test "a refresh may name the grant's own resource and no other" do
sign_in_as :david
connect(account: accounts("37s"))
untenanted do
post oauth_token_path, params: { grant_type: "refresh_token", client_id: @client.client_id, refresh_token: @token_response["refresh_token"], resource: "http://www.example.com/" }, as: :json
end
assert_response :bad_request
assert_equal "invalid_target", response.parsed_body["error"]
untenanted do
post oauth_token_path, params: { grant_type: "refresh_token", client_id: @client.client_id, refresh_token: @token_response["refresh_token"], resource: MCP_RESOURCE }, as: :json
end
assert_response :success
end
test "a refresh naming a whitespace resource is invalid_target, not taken as naming none" do
sign_in_as :david
connect(account: accounts("37s"))
untenanted do
post oauth_token_path, params: { grant_type: "refresh_token", client_id: @client.client_id, refresh_token: @token_response["refresh_token"], resource: " " }, as: :json
end
assert_response :bad_request
assert_equal "invalid_target", response.parsed_body["error"]
end
test "a grant for an account the person has since left doesn't refresh" do
sign_in_as :david
connect(account: accounts(:initech))
@david_at_initech.destroy
untenanted do
post oauth_token_path, params: { grant_type: "refresh_token", client_id: @client.client_id, refresh_token: @token_response["refresh_token"] }, as: :json
end
assert_response :bad_request
assert_equal "invalid_grant", response.parsed_body["error"]
end
test "an unaudienced grant can't be refreshed into an audienced one" do
grant = identities(:david).access_tokens.create!(oauth_client: @client)
untenanted do
post oauth_token_path, params: { grant_type: "refresh_token", client_id: @client.client_id, refresh_token: grant.raw_refresh_token, resource: MCP_RESOURCE }, as: :json
end
assert_response :bad_request
assert_equal "invalid_target", response.parsed_body["error"]
assert_nil grant.reload.audience
end
test "a code redeems only with its own resource" do
sign_in_as :david
code = consent_code(resource: MCP_RESOURCE, account_id: accounts("37s").id)
assert_no_difference "Identity::AccessToken.count" do
redeem code, resource: "https://evil.example.com/mcp"
end
assert_response :bad_request
assert_equal "invalid_target", response.parsed_body["error"]
end
test "an unaudienced code can't be redeemed for the MCP resource" do
sign_in_as :david
code = consent_code
assert_no_difference "Identity::AccessToken.count" do
redeem code, resource: MCP_RESOURCE
end
assert_response :bad_request
assert_equal "invalid_target", response.parsed_body["error"]
end
test "an unaudienced code redeems naming Fizzy's own API" do
sign_in_as :david
code = consent_code
redeem code, resource: "http://www.example.com/"
assert_response :success
grant = Identity::AccessToken.find_by_raw_token(response.parsed_body["access_token"])
assert_not grant.audienced?
assert_not grant.account_bound?
end
test "consent refuses an account the person doesn't belong to" do
sign_in_as :david
post_consent resource: MCP_RESOURCE, account_id: accounts(:acme).id
assert_response :bad_request
assert_select "[data-oauth-error=?]", "invalid_request"
end
test "consent for the MCP resource tells the client no for someone with no account to connect" do
sign_in_as Identity.create!(email_address: "nobody@example.com")
get_consent resource: MCP_RESOURCE
assert_response :redirect
assert_equal "access_denied", redirect_params["error"]
end
test "an unvetted client is shown, not sent, that there's no account to connect" do
sign_in_as Identity.create!(email_address: "nobody@example.com")
@client = Oauth::Client.create!(name: "Hosted", redirect_uris: %w[ https://connector.example.com/callback ], scopes: %w[ read write ], dynamically_registered: true)
get_consent resource: MCP_RESOURCE
assert_response :bad_request
assert_select "[data-oauth-error=?]", "access_denied"
end
test "a resource repeated as a bare key is invalid_target, not the last one named" do
sign_in_as :david
untenanted do
get "#{new_oauth_authorization_path}?#{consent_params.to_query}&resource=#{CGI.escape(MCP_RESOURCE)}&resource=#{CGI.escape("http://www.example.com/")}"
end
assert_response :redirect
assert_equal "invalid_target", redirect_params["error"]
end
test "a resource repeated as a bare key in a token request's form body is invalid_target" do
sign_in_as :david
code = consent_code(resource: MCP_RESOURCE, account_id: accounts("37s").id)
body = { grant_type: "authorization_code", client_id: @client.client_id, code: code,
redirect_uri: @client.redirect_uris.first, code_verifier: CODE_VERIFIER }.to_query
body += "&resource=#{CGI.escape("http://www.example.com/")}&resource=#{CGI.escape(MCP_RESOURCE)}"
assert_no_difference "Identity::AccessToken.count" do
untenanted do
post oauth_token_path, params: body, headers: { "CONTENT_TYPE" => "application/x-www-form-urlencoded" }
end
end
assert_response :bad_request
assert_equal "invalid_target", response.parsed_body["error"]
end
test "denying an audienced request tells the client no, even with no account to connect" do
sign_in_as Identity.create!(email_address: "nobody@example.com")
post_consent resource: MCP_RESOURCE, error: "access_denied"
assert_response :redirect
assert_equal "access_denied", redirect_params["error"]
end
test "a resource repeated in a multipart token request is invalid_target" do
sign_in_as :david
code = consent_code(resource: MCP_RESOURCE, account_id: accounts("37s").id)
assert_no_difference "Identity::AccessToken.count" do
untenanted do
post oauth_token_path, **multipart(redeem_fields(code), resource: [ "http://www.example.com/", MCP_RESOURCE ])
end
end
assert_response :bad_request
assert_equal "invalid_target", response.parsed_body["error"]
end
test "a resource repeated in a multipart refresh is invalid_target" do
grant = identities(:david).access_tokens.create!(oauth_client: @client, audience: MCP_RESOURCE, account_id: accounts("37s").id)
untenanted do
post oauth_token_path, **multipart({ grant_type: "refresh_token", client_id: @client.client_id, refresh_token: grant.raw_refresh_token },
resource: [ "http://www.example.com/", MCP_RESOURCE ])
end
assert_response :bad_request
assert_equal "invalid_target", response.parsed_body["error"]
end
test "a resource repeated as a JSON key is invalid_target, not the last one named" do
sign_in_as :david
code = consent_code(resource: MCP_RESOURCE, account_id: accounts("37s").id)
json = redeem_fields(code).to_json.delete_suffix("}") + %(,"resource":"http://www.example.com/","resource":"#{MCP_RESOURCE}"})
assert_no_difference "Identity::AccessToken.count" do
untenanted do
post oauth_token_path, params: json, headers: { "CONTENT_TYPE" => "application/json" }
end
end
assert_response :bad_request
assert_equal "invalid_target", response.parsed_body["error"]
end
test "a resource in a multipart consent is invalid_target, however many times it's named" do
sign_in_as :david
[ [ MCP_RESOURCE ], [ "http://www.example.com/", MCP_RESOURCE ] ].each do |resources|
untenanted do
post oauth_authorization_path, **multipart(consent_params(account_id: accounts("37s").id), resource: resources)
end
assert_response :redirect
assert_equal "invalid_target", redirect_params["error"]
end
end
test "a resource in a multipart body of an authorization request is invalid_target" do
sign_in_as :david
request = multipart({}, resource: [ MCP_RESOURCE ])
untenanted do
get "#{new_oauth_authorization_path}?#{consent_params(resource: "http://www.example.com/").to_query}",
env: { "CONTENT_TYPE" => request[:headers]["CONTENT_TYPE"], "rack.input" => StringIO.new(request[:params]) }
end
assert_response :redirect
assert_equal "invalid_target", redirect_params["error"]
end
test "a resource sent once in a JSON or form body still redeems" do
sign_in_as :david
untenanted do
post oauth_token_path, params: redeem_fields(consent_code(resource: MCP_RESOURCE, account_id: accounts("37s").id)).merge(resource: MCP_RESOURCE).to_json,
headers: { "CONTENT_TYPE" => "application/json" }
end
assert_response :success
untenanted do
post oauth_token_path, params: redeem_fields(consent_code(resource: MCP_RESOURCE, account_id: accounts("37s").id)).merge(resource: MCP_RESOURCE).to_query,
headers: { "CONTENT_TYPE" => "application/x-www-form-urlencoded" }
end
assert_response :success
end
test "a code for an account the person has since left doesn't redeem" do
sign_in_as :david
code = consent_code(resource: MCP_RESOURCE, account_id: accounts(:initech).id)
@david_at_initech.destroy
assert_no_difference "Identity::AccessToken.count" do
redeem code, resource: MCP_RESOURCE
end
assert_response :bad_request
assert_equal "invalid_grant", response.parsed_body["error"]
end
test "Fizzy's API refuses an audienced token" do
grant = identities(:david).access_tokens.create!(oauth_client: @client, permission: :write, audience: MCP_RESOURCE, account_id: accounts("37s").id)
get user_path(users(:david)), env: bearer(grant), as: :json
assert_response :unauthorized
assert_match 'error="invalid_token"', response.headers["WWW-Authenticate"]
end
test "an account-bound token reaches its own account and no other" do
grant = identities(:david).access_tokens.create!(oauth_client: @client, account_id: accounts("37s").id)
get user_path(users(:david)), env: bearer(grant), as: :json
assert_response :success
get user_path(@david_at_initech, script_name: "/#{accounts(:initech).external_account_id}"), env: bearer(grant), as: :json
assert_response :unauthorized
assert_match 'error="invalid_token"', response.headers["WWW-Authenticate"]
end
test "an identity-wide token still reaches every account" do
grant = identities(:david).access_tokens.create!(oauth_client: @client)
get user_path(@david_at_initech, script_name: "/#{accounts(:initech).external_account_id}"), env: bearer(grant), as: :json
assert_response :success
end
test "outside any account, an account-bound token is refused unless the endpoint answers for its account alone" do
grant = identities(:david).access_tokens.create!(oauth_client: @client, permission: :write, account_id: accounts("37s").id)
untenanted do
get my_access_tokens_path, env: bearer(grant), as: :json
end
assert_response :unauthorized
assert_match 'error="invalid_token"', response.headers["WWW-Authenticate"]
end
test "my identity lists only the bound account to an account-bound token" do
grant = identities(:david).access_tokens.create!(oauth_client: @client, account_id: accounts(:initech).id)
untenanted do
get my_identity_path, env: bearer(grant), as: :json
end
assert_response :success
assert_equal [ accounts(:initech).id ], response.parsed_body["accounts"].map { |account| account["id"] }
end
test "my identity lists every account to an identity-wide token" do
grant = identities(:david).access_tokens.create!(oauth_client: @client)
untenanted do
get my_identity_path, env: bearer(grant), as: :json
end
assert_response :success
assert_equal [ accounts("37s").id, accounts(:initech).id ].sort, response.parsed_body["accounts"].map { |account| account["id"] }.sort
end
test "only an operator-provisioned confidential client may front a protected resource" do
public_client = Oauth::Client.new(name: "Public", redirect_uris: %w[ http://127.0.0.1:9999/cb ], protected_resource: MCP_RESOURCE)
assert_not public_client.valid?
assert public_client.errors.added?(:protected_resource, "may only be set on an operator-provisioned confidential client")
registered = Oauth::Client.new(name: "Registered", redirect_uris: %w[ https://connector.example.com/cb ], dynamically_registered: true,
token_endpoint_auth_method: "client_secret_post", protected_resource: MCP_RESOURCE)
assert_not registered.valid?
assert registered.errors.include?(:protected_resource)
provisioned = Oauth::Client.create!(name: "fizzy-mcp-server", redirect_uris: %w[ https://mcp.example.com/cb ],
token_endpoint_auth_method: "client_secret_post", protected_resource: MCP_RESOURCE)
assert provisioned.fronts?(MCP_RESOURCE)
assert_not provisioned.fronts?("https://other.example.com/mcp")
assert_not @client.fronts?(MCP_RESOURCE)
end
private
def consent_params(**overrides)
{
client_id: @client.client_id,
redirect_uri: @client.redirect_uris.first,
response_type: "code",
code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(CODE_VERIFIER), padding: false),
code_challenge_method: "S256",
scope: "read write",
state: "xyz123"
}.merge(overrides).compact
end
def get_consent(**params)
untenanted { get new_oauth_authorization_path, params: consent_params(**params) }
end
def post_consent(**params)
untenanted { post oauth_authorization_path, params: consent_params(**params) }
end
def consent_code(**params)
post_consent(**params)
assert_response :redirect
redirect_params.fetch("code")
end
def redeem(code, **params)
untenanted do
post oauth_token_path, params: { grant_type: "authorization_code", client_id: @client.client_id, code: code,
redirect_uri: @client.redirect_uris.first, code_verifier: CODE_VERIFIER, **params }, as: :json
end
end
def connect(account:)
redeem consent_code(resource: MCP_RESOURCE, account_id: account.id), resource: MCP_RESOURCE
assert_response :success
@token_response = response.parsed_body
Identity::AccessToken.find_by_raw_token(@token_response["access_token"])
end
def redeem_fields(code)
{ grant_type: "authorization_code", client_id: @client.client_id, code: code,
redirect_uri: @client.redirect_uris.first, code_verifier: CODE_VERIFIER }
end
# A multipart/form-data body naming each field once, and resource once per value.
def multipart(fields, resource:)
boundary = "LaneBBoundary"
parts = fields.map { |name, value| [ name.to_s, value.to_s ] } + resource.map { |value| [ "resource", value ] }
body = parts.map { |name, value| "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"\r\n\r\n#{value}\r\n" }.join + "--#{boundary}--\r\n"
{ params: body, headers: { "CONTENT_TYPE" => "multipart/form-data; boundary=#{boundary}" } }
end
def redirect_params
URI.decode_www_form(URI.parse(response.location).query).to_h
end
def bearer(grant)
{ "HTTP_AUTHORIZATION" => "Bearer #{grant.raw_token}" }
end
end