test/integration/oauth_refresh_replay_test.rb
Rama main · réplica de lectura del repositorio original.
require "test_helper"
# Refresh token replay (OAuth 2.1 §4.3.1, RFC 9700 §4.14.2): presenting a
# rotated refresh token again revokes the grant, unless it's a retry of the
# rotation that just happened.
class OauthRefreshReplayTest < ActionDispatch::IntegrationTest
setup do
@client = oauth_clients(:mcp_client)
@grant = identities(:david).access_tokens.create!(oauth_client: @client, permission: :read)
freeze_time
end
test "a retry within the grace window gets the same successor, and rotates nothing" do
presented = @grant.raw_refresh_token
refresh presented
first = response.parsed_body
travel Oauth::RetiredRefreshToken::GRACE - 1.second
refresh presented
assert_response :success
assert_equal first.slice("access_token", "refresh_token", "scope"), response.parsed_body.slice("access_token", "refresh_token", "scope")
assert_equal TokenDigester.digest(first["refresh_token"]), @grant.reload.refresh_token_digest
end
test "a retry within the grace window whose successor pair is gone is refused, without revoking" do
presented = @grant.raw_refresh_token
refresh presented
successor = response.parsed_body
Oauth::RetiredRefreshToken.update_all successor_payload: nil
travel 1.second
refresh presented
assert_response :bad_request
assert_equal "invalid_grant", response.parsed_body["error"]
assert Identity::AccessToken.exists?(@grant.id)
assert_equal TokenDigester.digest(successor["refresh_token"]), @grant.reload.refresh_token_digest
end
test "a replay at the end of the grace window revokes the grant" do
presented = @grant.raw_refresh_token
refresh presented
successor = response.parsed_body
travel Oauth::RetiredRefreshToken::GRACE
refresh presented
assert_response :bad_request
assert_equal "invalid_grant", response.parsed_body["error"]
assert_grant_revoked successor
end
test "a replay long after rotation revokes the grant" do
presented = @grant.raw_refresh_token
refresh presented
successor = response.parsed_body
travel 1.day
refresh presented
assert_response :bad_request
assert_equal "invalid_grant", response.parsed_body["error"]
assert_grant_revoked successor
end
test "a replay of an older ancestor revokes the grant" do
ancestor = @grant.raw_refresh_token
refresh ancestor
travel 1.hour
refresh response.parsed_body["refresh_token"]
successor = response.parsed_body
travel 1.hour
refresh ancestor
assert_response :bad_request
assert_grant_revoked successor
end
test "a retry within the grace window after the successor has itself rotated is refused, without revoking" do
ancestor = @grant.raw_refresh_token
refresh ancestor
travel 1.second
refresh response.parsed_body["refresh_token"]
successor = response.parsed_body
travel 1.second
refresh ancestor
assert_response :bad_request
assert_equal "invalid_grant", response.parsed_body["error"]
assert Identity::AccessToken.exists?(@grant.id)
assert_equal TokenDigester.digest(successor["refresh_token"]), @grant.reload.refresh_token_digest
end
test "a superseded retry within the grace window is refused as superseded, whatever scope it asks for" do
ancestor = @grant.raw_refresh_token
refresh ancestor
travel 1.second
refresh response.parsed_body["refresh_token"]
travel 1.second
[ "admin", "", "write" ].each do |scope|
untenanted do
post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: ancestor, client_id: @client.client_id, scope: scope }
end
assert_response :bad_request
assert_equal "invalid_grant", response.parsed_body["error"], "scope #{scope.inspect}"
end
assert Identity::AccessToken.exists?(@grant.id)
end
test "a replay asking for a scope beyond the grant still revokes it" do
presented = @grant.raw_refresh_token
refresh presented
successor = response.parsed_body
travel 1.day
untenanted do
post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: presented, client_id: @client.client_id, scope: "admin" }
end
assert_response :bad_request
assert_equal "invalid_grant", response.parsed_body["error"]
assert_grant_revoked successor
end
test "a retry within the grace window weighs its scope as a live refresh would" do
presented = @grant.raw_refresh_token
refresh presented
successor = response.parsed_body
[ "read write", "", "admin" ].each do |scope|
untenanted do
post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: presented, client_id: @client.client_id, scope: scope }
end
assert_response :bad_request, scope.inspect
assert_equal "invalid_scope", response.parsed_body["error"], scope.inspect
end
assert_equal TokenDigester.digest(successor["refresh_token"]), @grant.reload.refresh_token_digest
end
test "a retry within the grace window must ask for the scope the rotation produced" do
@grant.update! permission: :write
presented = @grant.raw_refresh_token
refresh presented
successor = response.parsed_body
assert_equal "read write", successor["scope"]
untenanted do
post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: presented, client_id: @client.client_id, scope: "read" }
end
assert_response :bad_request
assert_equal "invalid_scope", response.parsed_body["error"]
untenanted do
post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: presented, client_id: @client.client_id, scope: "read write" }
end
assert_response :success
assert_equal successor["refresh_token"], response.parsed_body["refresh_token"]
end
test "a successor rotated by a host whose clock runs behind still supersedes the retry" do
ancestor = @grant.raw_refresh_token
refresh ancestor
travel(-5.seconds)
refresh response.parsed_body["refresh_token"]
successor = response.parsed_body
travel 10.seconds
refresh ancestor
assert_response :bad_request
assert_equal "invalid_grant", response.parsed_body["error"]
assert_equal TokenDigester.digest(successor["refresh_token"]), @grant.reload.refresh_token_digest
end
test "a replay whose grant vanished mid-request is refused, not an error" do
presented = @grant.raw_refresh_token
loser = Identity::AccessToken.find(@grant.id)
refresh presented
Identity::AccessToken.where(id: @grant.id).delete_all
Identity::AccessToken.stubs(:find_by_refresh_token).returns(loser)
refresh presented
assert_response :bad_request
assert_equal "invalid_grant", response.parsed_body["error"]
end
test "another client replaying a rotated token is refused, without revoking" do
presented = @grant.raw_refresh_token
refresh presented
travel 1.day
refresh presented, client_id: oauth_clients(:trusted_client).client_id
assert_response :bad_request
assert_equal "invalid_grant", response.parsed_body["error"]
assert Identity::AccessToken.exists?(@grant.id)
end
test "a refresh that loses a concurrent rotation gets the winner's successor" do
presented = @grant.raw_refresh_token
loser = Identity::AccessToken.find(@grant.id)
refresh presented
winner = response.parsed_body
Identity::AccessToken.stubs(:find_by_refresh_token).returns(loser)
refresh presented
assert_response :success
assert_equal winner.slice("access_token", "refresh_token"), response.parsed_body.slice("access_token", "refresh_token")
end
test "revoking a rotated refresh token revokes the grant" do
presented = @grant.raw_refresh_token
refresh presented
successor = response.parsed_body
untenanted { post oauth_revocation_path, params: { token: presented, client_id: @client.client_id } }
assert_response :success
assert_grant_revoked successor
end
test "another client cannot revoke through a rotated refresh token" do
presented = @grant.raw_refresh_token
refresh presented
untenanted { post oauth_revocation_path, params: { token: presented, client_id: oauth_clients(:trusted_client).client_id } }
assert_response :success
assert Identity::AccessToken.exists?(@grant.id)
end
private
def refresh(refresh_token, client_id: @client.client_id)
untenanted do
post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: refresh_token, client_id: client_id }
end
end
def assert_grant_revoked(successor)
assert_not Identity::AccessToken.exists?(@grant.id)
refresh successor["refresh_token"]
assert_response :bad_request
end
end