Ctrl K
ComunidadGuías…

test/integration/oauth_audience_test.rb

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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