Ctrl K
ComunidadGuías…

test/integration/oauth_platform_clients_test.rb

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

Archivo original ↗Preguntar a KiBot
require "test_helper"

# What the connector platforms (Claude, ChatGPT, Cursor, Muse) need of the
# token endpoint and consent: their users share each platform's egress, and
# they ask for offline_access beside the scopes they want.
class OauthPlatformClientsTest < ActionDispatch::IntegrationTest
  CODE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
  ADDRESS_BUCKET = "rate-limit:oauth/tokens:127.0.0.1"
  CEILING_BUCKET = "rate-limit:oauth/tokens:ceiling:127.0.0.1"

  setup do
    @client = oauth_clients(:mcp_client)
    @grant = identities(:david).access_tokens.create!(oauth_client: @client, permission: :write)
  end

  test "offline_access rides along on consent, and the grant is what the other scopes name" do
    sign_in_as :david

    untenanted { get new_oauth_authorization_path, params: consent_params(scope: "read write offline_access") }
    assert_response :success

    untenanted { post oauth_authorization_path, params: consent_params(scope: "read write offline_access") }
    assert_response :redirect
    code = Rack::Utils.parse_query(URI.parse(response.location).query)["code"]
    assert code

    redeem code
    assert_response :success
    assert_equal "read write", response.parsed_body["scope"]
    assert response.parsed_body["refresh_token"].present?, "a refresh token, as for every grant"
  end

  test "offline_access alone asks for nothing" do
    sign_in_as :david

    untenanted { get new_oauth_authorization_path, params: consent_params(scope: "offline_access") }
    assert_response :redirect
    assert_equal "invalid_scope", Rack::Utils.parse_query(URI.parse(response.location).query)["error"]
  end

  test "a refresh naming offline_access is weighed without it" do
    refresh @grant.raw_refresh_token, scope: "read write offline_access"
    assert_response :success
    assert_equal "read write", response.parsed_body["scope"]

    refresh response.parsed_body["refresh_token"], scope: "read offline_access"
    assert_response :success
    assert_equal "read", response.parsed_body["scope"], "it may still narrow"

    refresh response.parsed_body["refresh_token"], scope: "offline_access"
    assert_response :bad_request
    assert_equal "invalid_scope", response.parsed_body["error"]
  end

  test "a refresh is charged to its grant, so a full address doesn't stop it" do
    Rails.cache.stubs(:increment).returns(1)
    Rails.cache.stubs(:increment).with { |key, *| key == ADDRESS_BUCKET }.returns(1_000)
    Rails.cache.stubs(:increment).with { |key, *| key == grant_bucket(@grant) }.returns(1)

    refresh @grant.raw_refresh_token
    assert_response :success
  end

  test "a grant's own budget holds it" do
    Rails.cache.stubs(:increment).returns(1)
    Rails.cache.stubs(:increment).with { |key, *| key == ADDRESS_BUCKET }.returns(1)
    Rails.cache.stubs(:increment).with { |key, *| key == grant_bucket(@grant) }.returns(Oauth::TokensController::GRANT_BUDGET + 1)

    refresh @grant.raw_refresh_token
    assert_response :too_many_requests
  end

  test "a rotated refresh token is charged to its own grant, not the address" do
    presented = @grant.raw_refresh_token
    refresh presented
    assert_response :success

    Rails.cache.stubs(:increment).returns(1)
    Rails.cache.stubs(:increment).with { |key, *| key == ADDRESS_BUCKET }.returns(1_000)
    Rails.cache.stubs(:increment).with { |key, *| key == grant_bucket(@grant) }.returns(1)

    travel Oauth::RetiredRefreshToken::GRACE + 1.second
    refresh presented
    assert_response :bad_request, "its replay is judged, not throttled by its neighbors"
    assert_equal "invalid_grant", response.parsed_body["error"]
  end

  test "a code is charged to its client and person, not the address" do
    Rails.cache.stubs(:increment).returns(1)
    Rails.cache.stubs(:increment).with { |key, *| key == ADDRESS_BUCKET }.returns(1_000)
    Rails.cache.stubs(:increment).with { |key, *| key == "rate-limit:oauth/tokens:grant:code/#{@client.id}/#{identities(:david).id}" }.returns(1)

    redeem authorization_code_for(identities(:david))
    assert_response :success
  end

  test "what names no grant of its own is held to the address" do
    Rails.cache.stubs(:increment).returns(21)

    {
      "an unknown refresh token" => -> { refresh "fizzy_rt_unknown" },
      "another client's grant" => -> { refresh @grant.raw_refresh_token, client_id: oauth_clients(:trusted_client).client_id },
      "a failed client authentication" => -> { refresh @grant.raw_refresh_token, client_secret: "wrong" },
      "a code issued to another client" => -> { redeem authorization_code_for(identities(:david), client: oauth_clients(:trusted_client)) },
      "a code that doesn't decrypt" => -> { redeem "not-a-code" }
    }.each do |name, request|
      request.call
      assert_response :too_many_requests, name
    end
  end

  test "past the address ceiling, nothing is looked up" do
    Rails.cache.stubs(:increment).returns(1)
    Rails.cache.stubs(:increment).with { |key, *| key == CEILING_BUCKET }.returns(Oauth::TokensController::ADDRESS_CEILING + 1)
    Identity::AccessToken.expects(:find_by_refresh_token).never
    Oauth::Client.expects(:find_presented).never

    refresh @grant.raw_refresh_token
    assert_response :too_many_requests
  end

  test "the address limit counts only what it holds" do
    Rails.cache.stubs(:increment).returns(1)
    Rails.cache.stubs(:increment).with { |key, *| key == ADDRESS_BUCKET }.returns(21)
    Rails.cache.stubs(:increment).with { |key, *| key != ADDRESS_BUCKET }.returns(1)

    refresh @grant.raw_refresh_token
    assert_response :success
  end

  private
    def grant_bucket(grant)
      "rate-limit:oauth/tokens:grant:grant/#{grant.id}"
    end

    def refresh(refresh_token, **params)
      untenanted do
        post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: refresh_token, client_id: @client.client_id, **params }
      end
    end

    def redeem(code)
      untenanted do
        post oauth_token_path, params: { grant_type: "authorization_code", client_id: @client.client_id, code: code,
          redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: CODE_VERIFIER }
      end
    end

    def consent_params(**overrides)
      {
        client_id: @client.client_id,
        redirect_uri: "http://127.0.0.1:8888/callback",
        response_type: "code",
        code_challenge: code_challenge,
        code_challenge_method: "S256",
        scope: "read",
        state: "xyz123"
      }.merge(overrides)
    end

    def code_challenge
      Base64.urlsafe_encode64(Digest::SHA256.digest(CODE_VERIFIER), padding: false)
    end

    def authorization_code_for(identity, client: @client)
      Oauth::AuthorizationCode.generate \
        client_id: client.client_id,
        identity_id: identity.id,
        code_challenge: code_challenge,
        redirect_uri: "http://127.0.0.1:8888/callback",
        scope: "read write"
    end
end