require "test_helper" # A grant lapses once its refresh token goes unused for the idle window, as # bc3's refresh tokens do: each refresh restarts the clock, and a lapsed grant # takes a fresh authorization, with consent. class OauthGrantIdleExpiryTest < ActionDispatch::IntegrationTest setup do @client = oauth_clients(:mcp_client) freeze_time @grant = identities(:david).access_tokens.create!(oauth_client: @client, permission: :read) end test "a grant refreshes up to the end of the idle window" do later_by Identity::AccessToken::REFRESH_IDLE_LIMIT refresh @grant.raw_refresh_token assert_response :success end test "a grant idle past the window lapses" do later_by Identity::AccessToken::REFRESH_IDLE_LIMIT + 1.second refresh @grant.raw_refresh_token assert_response :bad_request assert_equal "invalid_grant", response.parsed_body["error"] end test "each refresh restarts the idle clock" do later_by Identity::AccessToken::REFRESH_IDLE_LIMIT - 1.day refresh @grant.raw_refresh_token assert_response :success later_by Identity::AccessToken::REFRESH_IDLE_LIMIT - 1.day refresh response.parsed_body["refresh_token"] assert_response :success end test "a lapsed grant leaves Connected Apps" do sign_in_as :david later_by Identity::AccessToken::REFRESH_IDLE_LIMIT + 1.second get my_connected_apps_path assert_response :success assert_no_match @client.name, response.body end test "the sweep removes lapsed grants and keeps live ones" do live = identities(:david).access_tokens.create!(oauth_client: @client, permission: :read) personal = identities(:david).access_tokens.create!(permission: :read, description: "PAT") later_by Identity::AccessToken::REFRESH_IDLE_LIMIT - 1.day live.refresh later_by 1.day + 1.second Identity::AccessToken.cleanup assert_not Identity::AccessToken.exists?(@grant.id) assert Identity::AccessToken.exists?(live.id) assert Identity::AccessToken.exists?(personal.id) end test "the sweep spares a grant renewed after it was selected" do later_by Identity::AccessToken::REFRESH_IDLE_LIMIT + 1.second # The sweep holds the grant as it was read when selected, before the renewal. selection = stub selection.stubs(:find_each).yields(Identity::AccessToken.find(@grant.id)) Identity::AccessToken.stubs(:lapsed).returns(selection) Identity::AccessToken.where(id: @grant.id).update_all(updated_at: Time.current) Identity::AccessToken.cleanup assert Identity::AccessToken.exists?(@grant.id) end test "rotation itself refuses a lapsed grant, so one racing the deadline can't renew it" do later_by Identity::AccessToken::REFRESH_IDLE_LIMIT + 1.second presented = @grant.raw_refresh_token assert_nil @grant.refresh assert_equal TokenDigester.digest(presented), @grant.reload.refresh_token_digest end test "the idle clock is the grant's last rotation, so a rotation by older code mid-deploy restarts it too" do later_by Identity::AccessToken::REFRESH_IDLE_LIMIT - 1.day rotated_by_older_code = Identity::AccessToken.generate_unique_secure_token Identity::AccessToken.where(id: @grant.id).update_all(refresh_token_digest: TokenDigester.digest(rotated_by_older_code), updated_at: Time.current) later_by Identity::AccessToken::REFRESH_IDLE_LIMIT - 1.day refresh rotated_by_older_code assert_response :success end test "a lapsed grant takes its entry point off the profile page too" do sign_in_as :david get user_path(users(:david)) assert_match my_connected_apps_path, response.body later_by Identity::AccessToken::REFRESH_IDLE_LIMIT + 1.second get user_path(users(:david)) assert_no_match my_connected_apps_path, response.body end private # Elapsed time in the app's zone, as the model counts it: travel would add # calendar days in the local zone, an hour off across a DST change. def later_by(duration) travel_to Time.current + duration end def refresh(refresh_token) untenanted do post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: refresh_token, client_id: @client.client_id } end end end