diff --git a/app/controllers/oauth/revocations_controller.rb b/app/controllers/oauth/revocations_controller.rb index b41b486691..147835f61b 100644 --- a/app/controllers/oauth/revocations_controller.rb +++ b/app/controllers/oauth/revocations_controller.rb @@ -45,10 +45,14 @@ def revocable_token Identity::AccessToken.personal.find_by(token: params[:token]) || client_token end + # Either token of a grant revokes the whole grant, and so does a refresh + # token it has rotated away from. def client_token if client = authenticated_client tokens = Identity::AccessToken.oauth.where(oauth_client: client) - tokens.find_by(token: params[:token]) || tokens.find_by(refresh_token: params[:token]) + + tokens.find_by(token: params[:token]) || tokens.find_by(refresh_token: params[:token]) || + tokens.find_by(id: Oauth::RetiredRefreshToken.where(refresh_token: params[:token]).select(:access_token_id)) end end end diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index ce7601105e..11942ee4e4 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -32,7 +32,12 @@ class Oauth::TokensController < Oauth::BaseController before_action :set_identity end - before_action :set_refresh_scope, unless: :authorization_code_grant? + # A replay that can't be a retry is answered before any scope is weighed: + # outside the grace window it's revoked, so no scope parameter can steer it + # around the revocation it earns, and inside the window one whose successor + # has rotated is refused as superseded, whatever scope it asks for. A retry + # is weighed like the live refresh it repeats. + before_action :set_refresh_scope, unless: -> { authorization_code_grant? || unretryable_replay? } def create if authorization_code_grant? @@ -44,12 +49,15 @@ def create else oauth_error "invalid_grant", "Authorization code already used" end + elsif @retired_refresh_token + answer_refresh_replay @retired_refresh_token + elsif @access_token.refresh(permission: @refresh_permission) + render json: refresh_response(@access_token) + elsif retired_refresh_token = Oauth::RetiredRefreshToken.find_by(refresh_token: params[:refresh_token]) + # A concurrent refresh won the rotation: this is the same retry. + answer_refresh_replay retired_refresh_token else - if @access_token.refresh(permission: @refresh_permission) - render json: token_response(@access_token, scope: Oauth.canonical_scope(@access_token.permission)) - else - oauth_error "invalid_grant", "Invalid refresh token" - end + oauth_error "invalid_grant", "Invalid refresh token" end end @@ -116,8 +124,14 @@ def set_identity end end + # A rotated refresh token still resolves to its grant, so that presenting + # it again can be told apart as a retry or a replay once the client is + # known to be the grant's own. def set_refreshable_access_token - unless @access_token = Identity::AccessToken.oauth.find_by(refresh_token: params[:refresh_token]) + @access_token = Identity::AccessToken.find_by_refresh_token(params[:refresh_token]) || + (@retired_refresh_token = Oauth::RetiredRefreshToken.find_by(refresh_token: params[:refresh_token]))&.access_token + + unless @access_token oauth_error "invalid_grant", "Invalid refresh token" end end @@ -132,6 +146,12 @@ def validate_client_id end end + # Superseded only ever stays superseded, so a replay judged unretryable + # here is still unretryable when answer_refresh_replay reads the grant. + def unretryable_replay? + @retired_refresh_token && !@retired_refresh_token.retryable?(@access_token) + end + # A refresh request may narrow scope but never widen it (RFC 6749 §6). An # omitted scope keeps the original grant; a requested subset narrows the # rotated token; anything beyond the grant is invalid_scope. Only an absent @@ -169,6 +189,48 @@ def authenticate_client end end + # A retry of the rotation that just happened gets the successor that + # rotation issued, rather than a revocation for losing a response. Any + # other replay of a rotated refresh token is the theft signal OAuth 2.1 + # §4.3.1 describes, so the grant is revoked, successor and all. A retry + # whose successor has itself rotated is refused without revoking: the + # client holds the live descendant. So is one whose grant has just been + # deleted out from under it. + # + # The grant is read once, and the retry is judged against that same read, + # so a concurrent rotation can't let a newer pair out as the answer. It's + # read past the query cache, which may hold this same lookup from earlier + # in the request, before a revocation deleted the grant. + def answer_refresh_replay(retired_refresh_token) + if grant = Identity::AccessToken.uncached { Identity::AccessToken.find_by(id: retired_refresh_token.access_token_id) } + if retired_refresh_token.retryable?(grant) + answer_refresh_retry grant + elsif retired_refresh_token.within_grace? + oauth_error "invalid_grant", "Refresh token superseded" + else + grant.destroy + oauth_error "invalid_grant", "Refresh token reuse detected" + end + else + oauth_error "invalid_grant", "Invalid refresh token" + end + end + + # A retry repeats the rotation it lost the response to, so it must ask for + # what that rotation produced. One asking for less can't be handed the + # wider successor that already exists. + def answer_refresh_retry(grant) + if @refresh_permission == grant.permission + render json: refresh_response(grant) + else + oauth_error "invalid_scope", "Requested scope differs from the rotation being retried" + end + end + + def refresh_response(access_token) + token_response(access_token, scope: Oauth.canonical_scope(access_token.permission)) + end + def token_response(access_token, scope: nil) { access_token: access_token.token, diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb index bd7b003ff1..715c107fe1 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -3,6 +3,12 @@ class Identity::AccessToken < ApplicationRecord belongs_to :identity belongs_to :oauth_client, class_name: "Oauth::Client", optional: true + has_many :retired_refresh_tokens, class_name: "Oauth::RetiredRefreshToken", dependent: :delete_all + + # Rotation locks the grant and then adds a retired token. Destroying takes + # the same locks in the same order, grant first, so a refresh racing a + # revocation waits rather than deadlocking against the retired-token delete. + before_destroy :lock_grant, prepend: true scope :personal, -> { where oauth_client_id: nil } scope :oauth, -> { where.not oauth_client_id: nil } @@ -22,6 +28,10 @@ def find_honored(token) end end + def find_by_refresh_token(refresh_token) + oauth.find_by(refresh_token: refresh_token) + end + # An authorization code redeems at most once (RFC 6749 §4.1.2): the grant it # mints is stamped with the code's jti, and the unique index refuses a second # stamp. A replay also revokes the grant the first redemption issued. @@ -53,19 +63,28 @@ def expires_in end # Rotates atomically on the presented refresh token, so a concurrent - # rotation wins the row and the loser comes up empty-handed. + # rotation wins the row and the loser comes up empty-handed. The presented + # token is retired, not forgotten, so presenting it again is recognized as + # a retry or a replay (see Oauth::RetiredRefreshToken). def refresh(permission: self.permission) rotated = { token: self.class.generate_unique_secure_token, refresh_token: self.class.generate_unique_secure_token, expires_at: EXPIRES_IN.from_now, permission: permission, updated_at: Time.current } - if self.class.where(id: id, refresh_token: refresh_token).update_all(rotated) == 1 - assign_attributes rotated - true + transaction do + if self.class.where(id: id, refresh_token: refresh_token).update_all(rotated) == 1 + retired_refresh_tokens.create! refresh_token: refresh_token, successor_refresh_token: rotated[:refresh_token] + assign_attributes rotated + true + end end end private + def lock_grant + self.class.lock.where(id: id).pluck(:id) + end + def set_expiry_and_refresh_token self.expires_at ||= EXPIRES_IN.from_now self.refresh_token ||= self.class.generate_unique_secure_token diff --git a/app/models/oauth/retired_refresh_token.rb b/app/models/oauth/retired_refresh_token.rb new file mode 100644 index 0000000000..d1b16b2643 --- /dev/null +++ b/app/models/oauth/retired_refresh_token.rb @@ -0,0 +1,44 @@ +# A refresh token its grant has rotated away from. Presenting one again is +# either a client retrying a rotation whose response it lost, or someone +# replaying a stolen token (OAuth 2.1 §4.3.1). Within GRACE of the rotation, +# and only while the successor it produced is still the grant's current +# refresh token, it's a retry. +# Otherwise it's a replay, and the whole grant is revoked. +class Oauth::RetiredRefreshToken < ApplicationRecord + # bc3's refresh_replay_grace default (Oauth::RefreshToken::Rotation). + GRACE = 60.seconds + + # As long as a replay can still be recognized. It matches bc3's 90-day + # refresh_token_ttl, after which a rotated token there has expired too. + RETENTION = 90.days + + belongs_to :access_token, class_name: "Identity::AccessToken" + + scope :stale, -> { where(created_at: ...RETENTION.ago) } + + class << self + def cleanup + stale.in_batches.delete_all + end + end + + # Whether a replay now is a retry the grant can answer with the successor + # this rotation produced. Pass the grant as just read: comparing against + # that one read, rather than querying again, leaves no gap for a concurrent + # rotation to slip into. + def retryable?(grant) + within_grace? && !superseded?(grant) + end + + def within_grace? + created_at > GRACE.ago + end + + # The successor this rotation produced has been rotated in turn, so the + # client demonstrably received it and moved on. This is read from the + # grant's current token, never from timestamp order, which skewed host + # clocks could invert. + def superseded?(grant) + grant.refresh_token != successor_refresh_token + end +end diff --git a/config/recurring.yml b/config/recurring.yml index 89122dac9c..7097319553 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -27,6 +27,9 @@ production: &production cleanup_transfers: command: "Identity::Transfer.cleanup" schedule: every 4 hours + cleanup_oauth_retired_refresh_tokens: + command: "Oauth::RetiredRefreshToken.cleanup" + schedule: every day at 04:22 cleanup_exports: command: "Export.cleanup" schedule: every hour at minute 20 diff --git a/db/migrate/20261006120000_create_oauth_retired_refresh_tokens.rb b/db/migrate/20261006120000_create_oauth_retired_refresh_tokens.rb new file mode 100644 index 0000000000..41b1b68234 --- /dev/null +++ b/db/migrate/20261006120000_create_oauth_retired_refresh_tokens.rb @@ -0,0 +1,14 @@ +class CreateOauthRetiredRefreshTokens < ActiveRecord::Migration[8.2] + def change + create_table :oauth_retired_refresh_tokens, id: :uuid do |t| + t.uuid :access_token_id, null: false + t.string :refresh_token, null: false + t.string :successor_refresh_token, null: false + t.datetime :created_at, null: false + + t.index :refresh_token, unique: true + t.index :access_token_id + t.index :created_at + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 727fddd1ee..a95b556b21 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_08_28_120000) do +ActiveRecord::Schema[8.2].define(version: 2026_10_06_120000) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -450,6 +450,16 @@ t.index ["client_id"], name: "index_oauth_clients_on_client_id", unique: true end + create_table "oauth_retired_refresh_tokens", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "access_token_id", null: false + t.datetime "created_at", null: false + t.string "refresh_token", null: false + t.string "successor_refresh_token", null: false + t.index ["access_token_id"], name: "index_oauth_retired_refresh_tokens_on_access_token_id" + t.index ["created_at"], name: "index_oauth_retired_refresh_tokens_on_created_at" + t.index ["refresh_token"], name: "index_oauth_retired_refresh_tokens_on_refresh_token", unique: true + end + create_table "pins", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 0b5f7eb61f..4b67d952b6 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_08_28_120000) do +ActiveRecord::Schema[8.2].define(version: 2026_10_06_120000) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -450,6 +450,16 @@ t.index ["client_id"], name: "index_oauth_clients_on_client_id", unique: true end + create_table "oauth_retired_refresh_tokens", id: :uuid, force: :cascade do |t| + t.uuid "access_token_id", null: false + t.string "refresh_token", limit: 255, null: false + t.string "successor_refresh_token", limit: 255, null: false + t.datetime "created_at", null: false + t.index ["access_token_id"], name: "index_oauth_retired_refresh_tokens_on_access_token_id" + t.index ["created_at"], name: "index_oauth_retired_refresh_tokens_on_created_at" + t.index ["refresh_token"], name: "index_oauth_retired_refresh_tokens_on_refresh_token", unique: true + end + create_table "pins", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false diff --git a/lib/rails_ext/active_record_sql_redaction.rb b/lib/rails_ext/active_record_sql_redaction.rb index 4a6a36b941..e003c05031 100644 --- a/lib/rails_ext/active_record_sql_redaction.rb +++ b/lib/rails_ext/active_record_sql_redaction.rb @@ -12,6 +12,7 @@ # identity_transfers.token signs its holder in as the identity # magic_links.code signs its holder in as the identity # oauth_clients.client_secret authenticates a confidential client +# oauth_retired_refresh_tokens a retired refresh token and the live one that succeeded it # webhooks.signing_secret, .url signs payloads; a chat webhook URL is itself a credential # # Filtering by table rather than column keeps INSERT ... VALUES, multi-row inserts and IN lists covered @@ -22,7 +23,7 @@ # test/lib/rails_ext/active_record_sql_redaction_test.rb until its table is listed here or the column is # declared not secret there. A credential in a column named otherwise has to be listed by hand. module ActiveRecordSqlRedaction - TABLES = %w[ account_join_codes identity_access_tokens identity_transfers magic_links oauth_clients webhooks ].freeze + TABLES = %w[ account_join_codes identity_access_tokens identity_transfers magic_links oauth_clients oauth_retired_refresh_tokens webhooks ].freeze # Names a table however the statement spells it: `magic_links` on MySQL, "magic_links" on SQLite, or bare # in hand-written SQL, in any case. Over-matching only filters more. diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index ac4502a117..5146325e15 100644 --- a/test/integration/oauth_flow_test.rb +++ b/test/integration/oauth_flow_test.rb @@ -1316,6 +1316,7 @@ class OauthFlowTest < ActionDispatch::IntegrationTest end assert_response :success + travel Oauth::RetiredRefreshToken::GRACE + 1.second untenanted do post oauth_token_path, params: { grant_type: "refresh_token", diff --git a/test/integration/oauth_refresh_replay_test.rb b/test/integration/oauth_refresh_replay_test.rb new file mode 100644 index 0000000000..66a28ef7ae --- /dev/null +++ b/test/integration/oauth_refresh_replay_test.rb @@ -0,0 +1,241 @@ +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.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 first["refresh_token"], @grant.reload.refresh_token + end + + test "a replay at the end of the grace window revokes the grant" do + presented = @grant.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.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.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.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 successor["refresh_token"], @grant.reload.refresh_token + end + + test "a superseded retry within the grace window is refused as superseded, whatever scope it asks for" do + ancestor = @grant.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.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.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 successor["refresh_token"], @grant.reload.refresh_token + end + + test "a retry within the grace window must ask for the scope the rotation produced" do + @grant.update! permission: :write + presented = @grant.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.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 successor["refresh_token"], @grant.reload.refresh_token + end + + test "a replay whose grant vanished mid-request is refused, not an error" do + presented = @grant.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.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.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.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.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 diff --git a/test/models/oauth/retired_refresh_token_test.rb b/test/models/oauth/retired_refresh_token_test.rb new file mode 100644 index 0000000000..3d1c0118ea --- /dev/null +++ b/test/models/oauth/retired_refresh_token_test.rb @@ -0,0 +1,59 @@ +require "test_helper" + +class Oauth::RetiredRefreshTokenTest < ActiveSupport::TestCase + setup do + @grant = identities(:david).access_tokens.create!(oauth_client: oauth_clients(:mcp_client)) + end + + test "rotation retires the presented refresh token" do + presented = @grant.refresh_token + + assert @grant.refresh + + assert_equal [ presented ], @grant.retired_refresh_tokens.pluck(:refresh_token) + end + + test "a rotation that loses the race retires nothing and leaves the winner's successor retryable" do + presented = @grant.refresh_token + loser = Identity::AccessToken.find(@grant.id) + + assert @grant.refresh + assert_nil loser.refresh + + assert_equal 1, Oauth::RetiredRefreshToken.count + assert Oauth::RetiredRefreshToken.find_by(refresh_token: presented).retryable?(@grant.reload) + end + + test "revoking a grant deletes its retired refresh tokens" do + @grant.refresh + + assert_difference "Oauth::RetiredRefreshToken.count", -1 do + @grant.destroy + end + end + + test "destroying a grant locks it before deleting its retired tokens, the order rotation takes them in" do + @grant.refresh + statements = [] + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") { |*, payload| statements << payload[:sql] } + + @grant.destroy + + ActiveSupport::Notifications.unsubscribe(subscriber) + lock = statements.index { |sql| sql.match?(/\ASELECT \W?identity_access_tokens\W?\.\W?id\W? FROM \W?identity_access_tokens\W? WHERE/) } + delete = statements.index { |sql| sql.match?(/\ADELETE FROM \W?oauth_retired_refresh_tokens\W?/) } + assert lock && delete && lock < delete, statements.join("\n") + end + + test "cleanup deletes retired tokens past retention, keeping the rest" do + freeze_time + @grant.refresh + travel Oauth::RetiredRefreshToken::RETENTION - 1.day + @grant.refresh + + travel 1.day + 1.minute + assert_difference "Oauth::RetiredRefreshToken.count", -1 do + Oauth::RetiredRefreshToken.cleanup + end + end +end