Ctrl K
ComunidadGuías…

test/integration/oauth_token_exchange_test.rb

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

Archivo original ↗Preguntar a KiBot
require "test_helper"

# The delegation exchange (RFC 8693) the hosted MCP server uses, at the token
# endpoint. Its wire contract is bc3's (doc/oauth/mcp_token_delegation.md),
# which fizzy-mcp-server's exchange client is built against.
class OauthTokenExchangeTest < ActionDispatch::IntegrationTest
  include OauthAvailabilityTestHelper, OauthClientCredentialsTestHelper, DnsTestHelper, OauthAuditTestHelper

  RESOURCE = "https://mcp.example.com/mcp"
  GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
  ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"

  setup do
    @saved_resources = Rails.application.config.x.oauth.mcp_resources
    Rails.application.config.x.oauth.mcp_resources = [ RESOURCE ]

    @client = Oauth::FirstPartyClients.provision_mcp_server!(RESOURCE)
    @secret = @client.rotate_secret!
    @subject = audienced_grant
  end

  teardown do
    Rails.application.config.x.oauth.mcp_resources = @saved_resources
  end

  test "an audienced, account-bound grant is exchanged for a short-lived API token" do
    assert_difference -> { Identity::AccessToken.delegated.count }, +1 do
      exchange
    end

    assert_response :success
    body = response.parsed_body
    assert body["access_token"].start_with?("fizzy_dt_")
    assert_equal "Bearer", body["token_type"]
    assert_equal ACCESS_TOKEN_TYPE, body["issued_token_type"]
    assert_equal "read write", body["scope"]
    assert_equal Oauth::TokenDelegation::TTL_CAP.to_i, body["expires_in"], "a fresh delegated token reports its whole capped lifetime"
    assert_not body.key?("refresh_token")

    delegated = Identity::AccessToken.find_by_raw_token(body["access_token"])
    assert_equal @subject, delegated.delegated_from
    assert_equal @client, delegated.oauth_client
    assert_equal accounts("37s"), delegated.account
    assert_nil delegated.audience
    assert_nil delegated.refresh_token_digest
    assert delegated.write?
  end

  test "the delegated token authenticates the API, and the subject grant does not chain" do
    exchange
    delegated = response.parsed_body["access_token"]

    get user_path(users(:david)), env: { "HTTP_AUTHORIZATION" => "Bearer #{delegated}" }, as: :json
    assert_response :success

    exchange subject_token: delegated
    assert_invalid_grant "Subject token is not valid"
  end

  test "the delegated token reaches its subject's account alone, and discovers it at /my/identity" do
    accounts(:initech).users.create!(identity: identities(:david), name: "David", role: :member, verified_at: Time.current)
    exchange
    delegated = { "HTTP_AUTHORIZATION" => "Bearer #{response.parsed_body["access_token"]}" }

    untenanted { get my_identity_path, env: delegated, as: :json }
    assert_response :success
    assert_equal [ accounts("37s").id ], response.parsed_body["accounts"].map { |account| account["id"] }

    get users_path(script_name: "/#{accounts(:initech).external_account_id}"), env: delegated, as: :json
    assert_response :unauthorized
  end

  test "a subject about to expire is refused rather than traded for a token that dies in flight" do
    @subject.update_columns expires_at: (Oauth::TokenDelegation::MINIMUM_SUBJECT_LIFETIME - 1.second).from_now

    exchange
    assert_invalid_grant "Subject token is not valid"

    @subject.update_columns expires_at: (Oauth::TokenDelegation::MINIMUM_SUBJECT_LIFETIME + 5.seconds).from_now

    exchange
    assert_response :success
    assert_operator response.parsed_body["expires_in"], :>, 0
  end

  test "the token lives no longer than the subject" do
    @subject.update_columns expires_at: 90.seconds.from_now

    exchange

    assert_response :success
    assert_operator response.parsed_body["expires_in"], :<=, 90
  end

  test "the response always names the scope, and a requested scope only narrows" do
    exchange scope: "read"

    assert_response :success
    assert_equal "read", response.parsed_body["scope"]
    delegated = response.parsed_body["access_token"]

    patch user_path(users(:david)), params: { user: { name: "Nope" } }, env: { "HTTP_AUTHORIZATION" => "Bearer #{delegated}" }, as: :json
    assert_response :forbidden
  end

  test "a read grant gets a read token, and asking for write is refused" do
    @subject.update_columns permission: "read"

    exchange
    assert_response :success
    assert_equal "read", response.parsed_body["scope"]

    exchange scope: "read write"
    assert_oauth_error "invalid_scope", "Scope exceeds the subject token's scope"
  end

  test "an unknown or empty scope is refused" do
    exchange scope: "admin"
    assert_oauth_error "invalid_scope", "Unknown scope: admin"

    exchange scope: ""
    assert_oauth_error "invalid_scope", "Scope must include 'read' or 'write' for API access"
  end

  test "a scope the client may not hold is refused rather than narrowed" do
    @client.update! scopes: %w[ read ]

    exchange
    assert_oauth_error "invalid_scope", "Scope 'write' not allowed for this client"
  end

  test "tokens that aren't audienced to the client's resource are refused" do
    fizzy_grant = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:mcp_client), permission: :write, account: accounts("37s"))
    elsewhere = audienced_grant(audience: "https://other.example.com/mcp")

    [ fizzy_grant.raw_token, elsewhere.raw_token ].each do |token|
      exchange subject_token: token
      assert_invalid_grant "Subject token is not audienced to this client's protected resource"
    end

    exchange subject_token: identity_access_tokens(:davids_api_token).token
    assert_invalid_grant "Subject token is not valid"
  end

  test "a grant bound to no account is refused" do
    @subject.update_columns account_id: nil

    exchange
    assert_invalid_grant "Subject token is not account-bound"
  end

  test "a person who has left the account is refused" do
    users(:david).update_columns active: false

    exchange
    assert_invalid_grant "Subject token's user no longer has account access"
  end

  test "unknown, expired and lapsed subjects get the one answer" do
    exchange subject_token: "fizzy_at_nothing"
    assert_invalid_grant "Subject token is not valid"

    @subject.update_columns expires_at: 1.minute.ago
    exchange
    assert_invalid_grant "Subject token is not valid"

    @subject.update_columns expires_at: 1.hour.from_now, updated_at: (Identity::AccessToken::REFRESH_IDLE_LIMIT + 1.day).ago
    exchange
    assert_invalid_grant "Subject token is not valid"
  end

  test "a subject whose client OAuth is dark for is refused" do
    with_oauth_availability(acceptance: false, pilot_client_ids: [ @client.client_id ]) do
      exchange
    end

    assert_invalid_grant "Subject token is not valid"
  end

  test "only the MCP server's client, authenticated, may exchange" do
    exchange headers: {}, params: { client_id: @client.client_id, client_secret: "wrong" }
    assert_client_authentication_failed basic: false

    exchange headers: basic(@client.client_id, "wrong"), params: {}
    assert_client_authentication_failed

    exchange params: { client_id: oauth_clients(:mcp_client).client_id }
    assert_client_authentication_failed basic: false

    exchange params: { client_id: oauth_clients(:confidential_client).client_id, client_secret: "confidential_secret_789" }
    assert_oauth_error "unauthorized_client", "Client not configured for token delegation"
  end

  test "the client authenticates with HTTP Basic too" do
    exchange params: {}, headers: basic(@client.client_id, @secret)

    assert_response :success
  end

  test "a client whose resource is no longer configured can't exchange" do
    Rails.application.config.x.oauth.mcp_resources = [ RESOURCE, "https://other.example.com/mcp" ]
    @client.update! protected_resource: "https://retired.example.com/mcp"

    exchange
    assert_oauth_error "unauthorized_client", "Client not configured for token delegation"
  end

  test "an install that configures no resource answers as if the grant didn't exist" do
    Rails.application.config.x.oauth.mcp_resources = []

    exchange
    assert_oauth_error "unsupported_grant_type", "Only authorization_code and refresh_token grants are supported"
  end

  test "an exchange that isn't on answers the same past the rate limit" do
    ActiveSupport::Cache::NullStore.any_instance.stubs(:increment).returns(1_000)
    Rails.application.config.x.oauth.mcp_resources = []

    exchange
    assert_oauth_error "unsupported_grant_type", "Only authorization_code and refresh_token grants are supported"
  end

  test "a successful exchange is recorded under the grant it acts for" do
    events = capture_oauth_events { exchange }

    assert_response :success
    exchanged = oauth_event(events, "oauth.token.exchanged")
    assert_equal @subject.id, exchanged[:grant_id]
    assert_equal @client.client_id, exchanged[:client_id]
    assert_equal "token_exchange", exchanged.dig(:details, :grant_type) || exchanged[:grant_type]
    assert_no_secrets_in events, response.parsed_body["access_token"]
  end

  test "the exchange is dark while issuance is" do
    with_oauth_availability(acceptance: true, issuance: false) do
      exchange
    end

    assert_response :service_unavailable
  end

  test "a dark server answers 404 before saying whether it supports the exchange" do
    Oauth::TokenDelegation.stubs(:enabled?).returns(false)

    with_oauth_availability(acceptance: false) do
      exchange
    end

    assert_response :not_found
  end

  test "an exchange is timed as a token request of its own grant type" do
    timed = []
    callback = ->(event) { timed << event.payload[:grant_type] }

    ActiveSupport::Notifications.subscribed(callback, "token_request.oauth") { exchange }

    assert_response :success
    assert_equal %w[ token_exchange ], timed
  end

  test "a malformed request is invalid_request" do
    exchange subject_token: ""
    assert_oauth_error "invalid_request", "subject_token required"

    exchange subject_token_type: "urn:ietf:params:oauth:token-type:refresh_token"
    assert_oauth_error "invalid_request", "Only access_token subject_token_type supported"

    exchange requested_token_type: "urn:ietf:params:oauth:token-type:refresh_token"
    assert_oauth_error "invalid_request", "Only access_token requested_token_type supported"
  end

  test "a resource other than Fizzy's own API is invalid_target" do
    exchange resource: "http://www.example.com"
    assert_response :success

    untenanted { get "/.well-known/oauth-protected-resource" }
    exchange resource: response.parsed_body["resource"]
    assert_response :success

    exchange resource: RESOURCE
    assert_oauth_error "invalid_target", "resource does not match the subject token's account"
  end

  test "a subject token in the query string is refused, even with the body otherwise complete" do
    body = { grant_type: GRANT_TYPE, subject_token_type: ACCESS_TOKEN_TYPE, client_id: @client.client_id, client_secret: @secret }

    assert_no_difference -> { Identity::AccessToken.delegated.count } do
      untenanted do
        post oauth_token_path(subject_token: @subject.raw_token), params: body, headers: { "Content-Type" => "application/x-www-form-urlencoded" }
      end
    end

    assert_oauth_error "invalid_request", "subject_token must be sent in the request body"
  end

  test "several resources at once are invalid_target, even when the last names Fizzy's API" do
    body = URI.encode_www_form \
      grant_type: GRANT_TYPE, subject_token: @subject.raw_token, subject_token_type: ACCESS_TOKEN_TYPE,
      client_id: @client.client_id, client_secret: @secret
    body += "&" + URI.encode_www_form([ [ "resource", RESOURCE ], [ "resource", "http://www.example.com" ] ])

    assert_no_difference -> { Identity::AccessToken.delegated.count } do
      untenanted do
        post oauth_token_path, params: body, headers: { "Content-Type" => "application/x-www-form-urlencoded" }
      end
    end

    assert_oauth_error "invalid_target", "Only one resource may be named"
  end

  # Rails' name[]= list syntax, unlike a bare key repeated, is refused by the
  # token endpoint's single-valued check before the client is looked up.
  test "an exchange parameter sent as a list is invalid_request" do
    { resource: [ "http://www.example.com" ], scope: [ "read" ], subject_token: [ @subject.raw_token ],
      subject_token_type: [ ACCESS_TOKEN_TYPE ], requested_token_type: [ ACCESS_TOKEN_TYPE ], audience: [ "x" ] }.each do |name, value|
      assert_no_difference -> { Identity::AccessToken.delegated.count } do
        exchange name => value
      end

      assert_oauth_error "invalid_request", "#{name} must be a single string"
    end

    exchange params: { client_id: [ @client.client_id ], client_secret: @secret }
    assert_oauth_error "invalid_request", "client_id must be a single string"
  end

  test "only a form-urlencoded body is accepted, so no body can hide a repeated resource" do
    boundary = "fizzy-exchange"
    fields = { grant_type: GRANT_TYPE, subject_token: @subject.raw_token, subject_token_type: ACCESS_TOKEN_TYPE,
      client_id: @client.client_id, client_secret: @secret }.to_a + [ [ :resource, RESOURCE ], [ :resource, "http://www.example.com" ] ]
    multipart = fields.map { |name, value| "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"\r\n\r\n#{value}\r\n" }.join + "--#{boundary}--\r\n"

    assert_no_difference -> { Identity::AccessToken.delegated.count } do
      untenanted do
        post oauth_token_path, params: multipart, headers: { "Content-Type" => "multipart/form-data; boundary=#{boundary}" }
      end
    end
    assert_oauth_error "invalid_request", "Token exchange requests must be application/x-www-form-urlencoded"

    untenanted do
      post oauth_token_path, params: fields.first(5).to_h.to_json, headers: { "Content-Type" => "application/json" }
    end
    assert_oauth_error "invalid_request", "Token exchange requests must be application/x-www-form-urlencoded"
  end

  test "ending the subject grant sweeps its delegated tokens" do
    exchange
    delegated = response.parsed_body["access_token"]

    @subject.destroy

    assert_not Identity::AccessToken.find_by_raw_token(delegated)
    get user_path(users(:david)), env: { "HTTP_AUTHORIZATION" => "Bearer #{delegated}" }, as: :json
    assert_response :unauthorized
  end

  test "a delegated token is refused at use once its grant is gone, swept or not" do
    exchange
    delegated = response.parsed_body["access_token"]

    Identity::AccessToken.where(id: @subject.id).delete_all

    get user_path(users(:david)), env: { "HTTP_AUTHORIZATION" => "Bearer #{delegated}" }, as: :json
    assert_response :unauthorized
  end

  test "an exchange that loses the race with a revocation is refused" do
    Identity::AccessToken.stubs(:lock).returns(Identity::AccessToken.where(id: nil))

    exchange
    assert_invalid_grant "Subject token is not valid"
  end

  test "what a delegated token sets up is the connected app's, and goes with it on disconnect" do
    stub_web_push_dns_resolution
    subject = identities(:kevin).access_tokens.create! \
      oauth_client: oauth_clients(:mcp_client), permission: :write, audience: RESOURCE, account: accounts("37s")
    exchange subject_token: subject.raw_token
    delegated = { "HTTP_AUTHORIZATION" => "Bearer #{response.parsed_body["access_token"]}" }

    post board_webhooks_path(boards(:writebook)), params: { webhook: { name: "MCP feed", url: "https://mcp.example.com/hooks" } }, env: delegated, as: :json
    assert_response :created
    webhook = Webhook.find(response.parsed_body["id"])
    assert_equal oauth_clients(:mcp_client), webhook.created_via
    assert_includes Webhook.set_up_through(oauth_clients(:mcp_client), identity: identities(:kevin)), webhook

    post user_push_subscriptions_path(users(:kevin)), env: delegated, as: :json,
      params: { push_subscription: { endpoint: "https://fcm.googleapis.com/fcm/send/mcp-device", p256dh_key: "p256dh", auth_key: "auth" } }
    assert_equal oauth_clients(:mcp_client), Push::Subscription.find_by!(endpoint: "https://fcm.googleapis.com/fcm/send/mcp-device").oauth_client

    sign_in_as :kevin
    delete my_connected_app_path(oauth_clients(:mcp_client)), params: { remove_webhooks: "1", webhook_ids: [ webhook.id ] }

    assert_not Webhook.exists?(webhook.id)
    assert_not Push::Subscription.exists?(endpoint: "https://fcm.googleapis.com/fcm/send/mcp-device")
    assert_not Identity::AccessToken.delegated.where(delegated_from: subject).exists?
  end

  test "Connected Apps lists the subject grant, never the delegated tokens" do
    exchange

    assert_equal [ @subject ], identities(:david).access_tokens.grants.where(oauth_client: [ @client, @subject.oauth_client ]).to_a
  end

  test "the authenticated MCP client is charged to its own budget, not the per-IP limit" do
    Rails.cache.stubs(:increment).with { |key, *| key.include?(":client:") }.returns(Oauth::TokenExchangesController::CLIENT_BUDGET)
    Rails.cache.stubs(:increment).with { |key, *| !key.include?(":client:") }.returns(1_000)

    exchange
    assert_response :success

    Rails.cache.stubs(:increment).with { |key, *| key.include?(":client:") }.returns(Oauth::TokenExchangesController::CLIENT_BUDGET + 1)

    exchange
    assert_response :too_many_requests
    assert_equal "60", response.headers["Retry-After"]
  end

  test "a failed authentication is held to the per-IP limit" do
    Rails.cache.stubs(:increment).returns(21)

    exchange params: { client_id: @client.client_id, client_secret: "wrong" }
    assert_response :too_many_requests
  end

  test "the metadata advertises the exchange only where it's configured" do
    untenanted { get "/.well-known/oauth-authorization-server" }
    assert_includes response.parsed_body["grant_types_supported"], GRANT_TYPE

    Rails.application.config.x.oauth.mcp_resources = []
    untenanted { get "/.well-known/oauth-authorization-server" }
    assert_not_includes response.parsed_body["grant_types_supported"], GRANT_TYPE
  end

  test "a refresh still reaches the token endpoint beside the exchange" do
    untenanted do
      post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: @subject.raw_refresh_token,
        client_id: @subject.oauth_client.client_id }
    end

    assert_response :success
  end

  test "while connecting apps is in preview, a grant whose owner is a listed reviewer, not staff, is exchanged" do
    identities(:david).update!(staff: false)

    with_oauth_availability preview: true, reviewer_ids: [ identities(:david).id ] do
      exchange
      assert_response :success

      get user_path(users(:david)), env: { "HTTP_AUTHORIZATION" => "Bearer #{response.parsed_body["access_token"]}" }, as: :json
      assert_response :success
    end
  end

  test "while connecting apps is in preview, a grant whose owner isn't a previewer is not exchanged, and its delegated tokens stop working" do
    with_oauth_availability preview: true do
      exchange
      assert_response :success
      delegated = response.parsed_body["access_token"]

      identities(:david).update!(staff: false)

      get user_path(users(:david)), env: { "HTTP_AUTHORIZATION" => "Bearer #{delegated}" }, as: :json
      assert_response :unauthorized

      exchange
      assert_invalid_grant "Subject token is not valid"
    end
  end

  private
    def audienced_grant(audience: RESOURCE)
      identities(:david).access_tokens.create! \
        oauth_client: oauth_clients(:mcp_client), permission: :write, audience: audience, account: accounts("37s")
    end

    def exchange(subject_token: @subject.raw_token, params: nil, headers: {}, **extra)
      params ||= { client_id: @client.client_id, client_secret: @secret }
      body = { grant_type: GRANT_TYPE, subject_token: subject_token, subject_token_type: ACCESS_TOKEN_TYPE }.merge(extra).merge(params)

      untenanted { post oauth_token_path, params: body, headers: headers }
    end

    def assert_oauth_error(error, description)
      assert_response :bad_request
      assert_equal error, response.parsed_body["error"]
      assert_equal description, response.parsed_body["error_description"]
    end

    def assert_invalid_grant(description)
      assert_oauth_error "invalid_grant", description
    end
end