test/integration/oauth_revocation_test.rb
Rama main · réplica de lectura del repositorio original.
require "test_helper"
# RFC 7009 §2.1: the server validates the client's credentials and that the
# token was issued to it.
class OauthRevocationTest < ActionDispatch::IntegrationTest
include OauthClientCredentialsTestHelper
setup do
@client = oauth_clients(:confidential_client)
@secret = "confidential_secret_789"
end
# Revocation (RFC 7009 §2.1)
test "a confidential client must authenticate to revoke" do
token = grant_for(@client)
[ { client_id: @client.client_id }, { client_id: @client.client_id, client_secret: "wrong" } ].each do |credentials|
revoke token.raw_token, params: credentials
assert_client_authentication_failed credentials.inspect, basic: false
end
assert Identity::AccessToken.exists?(token.id)
end
test "a confidential client revokes its own token with client_secret_post or Basic" do
[ [ { client_id: @client.client_id, client_secret: @secret }, {} ], [ {}, basic(@client.client_id, @secret) ] ].each do |params, headers|
token = grant_for(@client)
revoke token.raw_refresh_token, params: params, headers: headers
assert_response :success
assert_not Identity::AccessToken.exists?(token.id)
end
end
test "an authenticated confidential client cannot revoke another client's token, and is told nothing" do
token = grant_for(oauth_clients(:mcp_client))
[ token.raw_token, token.raw_refresh_token ].each do |presented|
revoke presented, headers: basic(@client.client_id, @secret)
assert_response :success
end
assert Identity::AccessToken.exists?(token.id)
end
test "a case variant of a metadata document client's id names no client, and revokes nothing" do
client = Oauth::Client.create! name: "Claude", client_id: "https://claude.ai/client.json", redirect_uris: %w[ https://claude.ai/callback ], client_id_metadata_document: true
token = grant_for(client)
revoke token.raw_token, params: { client_id: "https://claude.ai/CLIENT.json" }
assert Identity::AccessToken.exists?(token.id)
end
test "a public client revokes its own token by naming its client_id" do
client = oauth_clients(:mcp_client)
[ :raw_token, :raw_refresh_token ].each do |kind|
token = grant_for(client)
revoke token.public_send(kind), params: { client_id: client.client_id }
assert_response :success
assert_not Identity::AccessToken.exists?(token.id), kind
end
end
test "a public client naming no client, or another client, revokes nothing and is told nothing" do
token = grant_for(oauth_clients(:mcp_client))
[ {}, { client_id: oauth_clients(:trusted_client).client_id }, { client_id: "no-such-client" } ].each do |params|
revoke token.raw_token, params: params
assert_response :success, params.inspect
end
assert Identity::AccessToken.exists?(token.id)
end
test "an unknown token answers 200" do
revoke "not-a-token", params: { client_id: oauth_clients(:mcp_client).client_id }
assert_response :success
revoke "not-a-token", headers: basic(@client.client_id, @secret)
assert_response :success
end
# A personal access token has no client, so possession is the credential:
# whoever holds one may revoke it, the same as signing out of it.
test "a personal access token is revoked by whoever presents it" do
[ {}, { client_id: oauth_clients(:mcp_client).client_id }, { client_id: "no-such-client" } ].each do |params|
token = personal_token
revoke token.raw_token, params: params
assert_response :success, params.inspect
assert_not Identity::AccessToken.exists?(token.id), params.inspect
end
end
test "a personal access token is revoked beside client credentials that authenticate" do
[ [ { client_id: @client.client_id, client_secret: @secret }, {} ], [ {}, basic(@client.client_id, @secret) ] ].each do |params, headers|
token = personal_token
revoke token.raw_token, params: params, headers: headers
assert_response :success
assert_not Identity::AccessToken.exists?(token.id)
end
end
test "client credentials that fail to authenticate are refused before a personal access token is revoked" do
token = personal_token
[ [ { client_id: @client.client_id }, {}, false ], [ { client_id: @client.client_id, client_secret: "wrong" }, {}, false ],
[ { client_secret: @secret }, {}, false ], [ { client_id: oauth_clients(:mcp_client).client_id, client_secret: "anything" }, {}, false ],
[ {}, basic(@client.client_id, "wrong"), true ], [ {}, basic("no-such-client", "x"), true ] ].each do |params, headers, via_basic|
revoke token.raw_token, params: params, headers: headers
assert_client_authentication_failed [ params, headers ].inspect, basic: via_basic
end
assert Identity::AccessToken.exists?(token.id)
end
test "a personal access token is not revoked by two client authentication methods at once" do
token = personal_token
revoke token.raw_token, params: { client_secret: @secret }, headers: basic(@client.client_id, @secret)
assert_response :bad_request
assert Identity::AccessToken.exists?(token.id)
end
test "tokens issued before prefixes revoke as before" do
personal = identities(:david).access_tokens.create!(permission: :read, token: "legacyPersonalToken0123456789")
grant = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:mcp_client),
raw_token: "legacyAccessToken0123456789", raw_refresh_token: "legacyRefreshToken0123456789")
revoke personal.raw_token
revoke grant.raw_refresh_token, params: { client_id: oauth_clients(:mcp_client).client_id }
assert_not Identity::AccessToken.exists?(personal.id)
assert_not Identity::AccessToken.exists?(grant.id)
end
test "a revoked personal access token no longer authenticates" do
token = personal_token
get user_path(users(:david)), env: { "HTTP_AUTHORIZATION" => "Bearer #{token.raw_token}" }, as: :json
assert_response :success
revoke token.raw_token
get user_path(users(:david)), env: { "HTTP_AUTHORIZATION" => "Bearer #{token.raw_token}" }, as: :json
assert_response :unauthorized
end
test "a Basic header that fails to authenticate is refused at revocation, whatever the token" do
token = grant_for(oauth_clients(:mcp_client))
[ basic("no-such-client", "x"), basic(@client.client_id, "wrong"), basic(oauth_clients(:mcp_client).client_id, "") ].each do |headers|
[ token.raw_token, "not-a-token" ].each do |presented|
revoke presented, headers: headers
assert_client_authentication_failed presented
end
end
assert Identity::AccessToken.exists?(token.id)
end
test "a body client_secret that authenticates no client is refused, whatever the token" do
token = grant_for(@client)
[ { client_secret: @secret }, { client_id: "no-such-client", client_secret: @secret },
{ client_id: oauth_clients(:mcp_client).client_id, client_secret: "anything" } ].each do |params|
[ token.raw_token, "not-a-token" ].each do |presented|
revoke presented, params: params
assert_client_authentication_failed params.inspect, basic: false
end
end
assert Identity::AccessToken.exists?(token.id)
end
test "a failed body authentication beside an Authorization header of another scheme is a 400: Basic was never evaluated" do
token = grant_for(@client)
revoke token.raw_token, params: { client_id: @client.client_id, client_secret: "wrong" }, headers: { "Authorization" => "Bearer #{token.raw_token}" }
assert_client_authentication_failed basic: false
assert Identity::AccessToken.exists?(token.id)
end
test "revocation refuses Basic beside a body client_secret" do
token = grant_for(@client)
revoke token.raw_token, params: { client_secret: @secret }, headers: basic(@client.client_id, @secret)
assert_response :bad_request
assert_equal "invalid_request", response.parsed_body["error"]
assert Identity::AccessToken.exists?(token.id)
end
# Revocation authenticates clients, so it is a place to guess secrets: it is
# throttled like the token endpoint (RFC 6749 §2.3.1, RFC 7009 §5).
test "revocation is rate limited like the token endpoint" do
ActiveSupport::Cache::NullStore.any_instance.stubs(:increment).returns(21)
revoke "not-a-token", params: { client_id: @client.client_id, client_secret: "guess" }
assert_response :too_many_requests
assert_equal "slow_down", response.parsed_body["error"]
end
test "metadata advertises client_secret_basic for revocation" do
untenanted { get "/.well-known/oauth-authorization-server" }
assert_equal %w[ none client_secret_post client_secret_basic ], response.parsed_body["revocation_endpoint_auth_methods_supported"]
end
private
def personal_token
identities(:david).access_tokens.create!(permission: :read)
end
def grant_for(client)
identities(:david).access_tokens.create!(oauth_client: client, permission: :read)
end
def revoke(token, params: {}, headers: {})
untenanted { post oauth_revocation_path, params: { token: token }.merge(params), headers: headers }
end
end