test/integration/oauth_platform_clients_test.rb
Rama main · réplica de lectura del repositorio original.
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