From 694da92f9cc7cb59f0488cf485eff00adaa10ca4 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 00:24:49 -0700 Subject: [PATCH 1/9] Revoke the grant when a rotated refresh token is replayed, with a retry grace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth 2.1 §4.3.1 and RFC 9700 §4.14.2: presenting a refresh token that has already been rotated means that either the client or an attacker holds a stale copy. The server can't tell which, so it revokes the grant. Before this, a replayed token simply failed as unknown, and the thief's copy, if it rotated first, kept the grant alive. Each rotation now retires the presented token into oauth_retired_refresh_tokens. When a retired token comes back from the grant's own client: - Within 60 seconds of its rotation, while the successor it produced is still current, the request is a retry of a lost response. It gets that same successor pair back, and nothing rotates. - Within the window, but after the successor has itself rotated, the request is refused as invalid_grant without revoking, since the client demonstrably holds the live descendant. - Otherwise the grant is revoked (successor included) and the request gets invalid_grant. A refresh that loses a concurrent rotation is answered the same way, so it gets the winner's successor. A replay from another client is refused as before and revokes nothing. The replay is judged before any requested scope, so an out-of-grant scope can't dodge the revocation. Revoking a rotated refresh token at /revoke revokes its grant. The 60-second window and its boundary (a replay at exactly 60s revokes) match bc3's refresh_replay_grace default and Oauth::RefreshToken::Rotation#within_grace_window?. Retired tokens are swept daily after 90 days, which matches bc3's refresh_token_ttl. --- .../oauth/revocations_controller.rb | 6 +- app/controllers/oauth/tokens_controller.rb | 47 +++++- app/models/identity/access_token.rb | 18 +- app/models/oauth/retired_refresh_token.rb | 37 +++++ config/recurring.yml | 3 + ...000_create_oauth_retired_refresh_tokens.rb | 13 ++ db/schema.rb | 11 +- db/schema_sqlite.rb | 11 +- test/integration/oauth_flow_test.rb | 1 + test/integration/oauth_refresh_replay_test.rb | 157 ++++++++++++++++++ .../oauth/retired_refresh_token_test.rb | 46 +++++ 11 files changed, 336 insertions(+), 14 deletions(-) create mode 100644 app/models/oauth/retired_refresh_token.rb create mode 100644 db/migrate/20261006120000_create_oauth_retired_refresh_tokens.rb create mode 100644 test/integration/oauth_refresh_replay_test.rb create mode 100644 test/models/oauth/retired_refresh_token_test.rb 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 e759956af9..29c64bffa5 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -31,7 +31,10 @@ class Oauth::TokensController < Oauth::BaseController before_action :set_identity end - before_action :set_refresh_scope, unless: :authorization_code_grant? + # A rotated refresh token is answered as a retry or a replay before any + # scope is weighed, so no scope parameter can steer a replay around the + # revocation it earns. + before_action :set_refresh_scope, unless: -> { authorization_code_grant? || @retired_refresh_token } def create if authorization_code_grant? @@ -43,12 +46,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 @@ -115,8 +121,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 @@ -168,6 +180,27 @@ 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. + def answer_refresh_replay(retired_refresh_token) + if retired_refresh_token.retryable? + render json: refresh_response(retired_refresh_token.access_token.reload) + elsif retired_refresh_token.within_grace? + oauth_error "invalid_grant", "Refresh token superseded" + else + retired_refresh_token.access_token.destroy + oauth_error "invalid_grant", "Refresh token reuse detected" + 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..576a63c226 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -3,6 +3,7 @@ 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 scope :personal, -> { where oauth_client_id: nil } scope :oauth, -> { where.not oauth_client_id: nil } @@ -22,6 +23,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,15 +58,20 @@ 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 + assign_attributes rotated + true + end end end diff --git a/app/models/oauth/retired_refresh_token.rb b/app/models/oauth/retired_refresh_token.rb new file mode 100644 index 0000000000..833247229a --- /dev/null +++ b/app/models/oauth/retired_refresh_token.rb @@ -0,0 +1,37 @@ +# 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 current, 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 + + def retryable? + within_grace? && !superseded? + 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. + def superseded? + access_token.retired_refresh_tokens.where("created_at > ?", created_at).exists? + 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..ac777348ba --- /dev/null +++ b/db/migrate/20261006120000_create_oauth_retired_refresh_tokens.rb @@ -0,0 +1,13 @@ +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.datetime :created_at, null: false + + t.index :refresh_token, unique: true + t.index [ :access_token_id, :created_at ], name: "index_oauth_retired_refresh_tokens_on_access_token_id" + t.index :created_at + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 727fddd1ee..8b4ae29c16 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,15 @@ 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.index ["access_token_id", "created_at"], 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..2a760570c8 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,15 @@ 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.datetime "created_at", null: false + t.index ["access_token_id", "created_at"], 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/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb index ac7dafd557..b48fca3e1d 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..c6d517dfca --- /dev/null +++ b/test/integration/oauth_refresh_replay_test.rb @@ -0,0 +1,157 @@ +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 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 "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..a004f63196 --- /dev/null +++ b/test/models/oauth/retired_refresh_token_test.rb @@ -0,0 +1,46 @@ +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? + 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 "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 From 136b9bf976e4b308dc491a285193a5f445954cfa Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 00:39:35 -0700 Subject: [PATCH 2/9] Read a grace retry's successor before checking that it's still current A concurrent rotation of the successor could land between the supersession check and the read, and the retry would then hand out that rotation's fresh pair. The read now comes first. A rotation that commits after the read shows up in the check and gets refused. A rotation that commits after the check leaves the read holding the successor this retry is owed. --- app/controllers/oauth/tokens_controller.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 29c64bffa5..b0fbb04965 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -186,9 +186,15 @@ def authenticate_client # §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. + # + # The successor is read before the retry check, so a rotation that lands + # between the two shows up as supersession and is refused. Checking first + # would let that rotation's fresh pair out as the retry's answer. def answer_refresh_replay(retired_refresh_token) + grant = retired_refresh_token.access_token.reload + if retired_refresh_token.retryable? - render json: refresh_response(retired_refresh_token.access_token.reload) + render json: refresh_response(grant) elsif retired_refresh_token.within_grace? oauth_error "invalid_grant", "Refresh token superseded" else From 63c2113770009b48d91478ec77ee8fb9da1e3def Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 01:09:13 -0700 Subject: [PATCH 3/9] Judge supersession by the successor a rotation produced, not by timestamps Each retired token now records the refresh token its rotation issued. A retry is answerable only while that successor is still the grant's current token. Before, a later retired row with a later created_at was taken to mean supersession, and hosts with skewed clocks, or two rotations landing on the same timestamp, could invert that order. A grace replay of an ancestor could then get the newest live pair. The retry is judged against a single read of the grant, so the check and the answer can't be separated by a concurrent rotation. A grant deleted mid-request answers invalid_grant instead of raising. --- app/controllers/oauth/tokens_controller.rb | 26 +++++++++-------- app/models/identity/access_token.rb | 2 +- app/models/oauth/retired_refresh_token.rb | 19 +++++++++---- ...000_create_oauth_retired_refresh_tokens.rb | 3 +- db/schema.rb | 3 +- db/schema_sqlite.rb | 3 +- test/integration/oauth_refresh_replay_test.rb | 28 +++++++++++++++++++ .../oauth/retired_refresh_token_test.rb | 2 +- 8 files changed, 63 insertions(+), 23 deletions(-) diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index b0fbb04965..f6522f2e14 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -185,21 +185,23 @@ def authenticate_client # 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. + # client holds the live descendant. So is one whose grant has just been + # deleted out from under it. # - # The successor is read before the retry check, so a rotation that lands - # between the two shows up as supersession and is refused. Checking first - # would let that rotation's fresh pair out as the retry's answer. + # 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. def answer_refresh_replay(retired_refresh_token) - grant = retired_refresh_token.access_token.reload - - if retired_refresh_token.retryable? - render json: refresh_response(grant) - elsif retired_refresh_token.within_grace? - oauth_error "invalid_grant", "Refresh token superseded" + if grant = Identity::AccessToken.find_by(id: retired_refresh_token.access_token_id) + if retired_refresh_token.retryable?(grant) + render json: refresh_response(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 - retired_refresh_token.access_token.destroy - oauth_error "invalid_grant", "Refresh token reuse detected" + oauth_error "invalid_grant", "Invalid refresh token" end end diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb index 576a63c226..380fc8eba4 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -68,7 +68,7 @@ def refresh(permission: self.permission) transaction do if self.class.where(id: id, refresh_token: refresh_token).update_all(rotated) == 1 - retired_refresh_tokens.create! refresh_token: refresh_token + retired_refresh_tokens.create! refresh_token: refresh_token, successor_refresh_token: rotated[:refresh_token] assign_attributes rotated true end diff --git a/app/models/oauth/retired_refresh_token.rb b/app/models/oauth/retired_refresh_token.rb index 833247229a..d1b16b2643 100644 --- a/app/models/oauth/retired_refresh_token.rb +++ b/app/models/oauth/retired_refresh_token.rb @@ -1,7 +1,8 @@ # 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 current, it's a retry. +# 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). @@ -21,8 +22,12 @@ def cleanup end end - def retryable? - within_grace? && !superseded? + # 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? @@ -30,8 +35,10 @@ def within_grace? end # The successor this rotation produced has been rotated in turn, so the - # client demonstrably received it and moved on. - def superseded? - access_token.retired_refresh_tokens.where("created_at > ?", created_at).exists? + # 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/db/migrate/20261006120000_create_oauth_retired_refresh_tokens.rb b/db/migrate/20261006120000_create_oauth_retired_refresh_tokens.rb index ac777348ba..41b1b68234 100644 --- a/db/migrate/20261006120000_create_oauth_retired_refresh_tokens.rb +++ b/db/migrate/20261006120000_create_oauth_retired_refresh_tokens.rb @@ -3,10 +3,11 @@ 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, :created_at ], name: "index_oauth_retired_refresh_tokens_on_access_token_id" + t.index :access_token_id t.index :created_at end end diff --git a/db/schema.rb b/db/schema.rb index 8b4ae29c16..a95b556b21 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -454,7 +454,8 @@ t.uuid "access_token_id", null: false t.datetime "created_at", null: false t.string "refresh_token", null: false - t.index ["access_token_id", "created_at"], name: "index_oauth_retired_refresh_tokens_on_access_token_id" + 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 diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 2a760570c8..4b67d952b6 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -453,8 +453,9 @@ 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", "created_at"], name: "index_oauth_retired_refresh_tokens_on_access_token_id" + 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 diff --git a/test/integration/oauth_refresh_replay_test.rb b/test/integration/oauth_refresh_replay_test.rb index c6d517dfca..8e509d8a21 100644 --- a/test/integration/oauth_refresh_replay_test.rb +++ b/test/integration/oauth_refresh_replay_test.rb @@ -94,6 +94,34 @@ class OauthRefreshReplayTest < ActionDispatch::IntegrationTest assert_grant_revoked successor 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 diff --git a/test/models/oauth/retired_refresh_token_test.rb b/test/models/oauth/retired_refresh_token_test.rb index a004f63196..c019a1b6f8 100644 --- a/test/models/oauth/retired_refresh_token_test.rb +++ b/test/models/oauth/retired_refresh_token_test.rb @@ -21,7 +21,7 @@ class Oauth::RetiredRefreshTokenTest < ActiveSupport::TestCase assert_nil loser.refresh assert_equal 1, Oauth::RetiredRefreshToken.count - assert Oauth::RetiredRefreshToken.find_by(refresh_token: presented).retryable? + assert Oauth::RetiredRefreshToken.find_by(refresh_token: presented).retryable?(@grant.reload) end test "revoking a grant deletes its retired refresh tokens" do From 3587f6cdea854cd642465939bfec2a0e22004123 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 01:26:41 -0700 Subject: [PATCH 4/9] Lock a grant before deleting its retired tokens Rotation locks the grant row and then inserts a retired token. Destroying a grant deleted the retired tokens first and the grant second. Under InnoDB, a refresh racing a revocation, disconnect or reuse revocation of the same grant could take those locks in opposite orders and deadlock, and the loser would get a 500. Destroy now locks the grant first, so both paths take the locks in the same order. --- app/models/identity/access_token.rb | 9 +++++++++ test/models/oauth/retired_refresh_token_test.rb | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb index 380fc8eba4..715c107fe1 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -5,6 +5,11 @@ class Identity::AccessToken < ApplicationRecord 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 } scope :active, -> { where(expires_at: nil).or(where(expires_at: Time.current..)) } @@ -76,6 +81,10 @@ def refresh(permission: self.permission) 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/test/models/oauth/retired_refresh_token_test.rb b/test/models/oauth/retired_refresh_token_test.rb index c019a1b6f8..3d1c0118ea 100644 --- a/test/models/oauth/retired_refresh_token_test.rb +++ b/test/models/oauth/retired_refresh_token_test.rb @@ -32,6 +32,19 @@ class Oauth::RetiredRefreshTokenTest < ActiveSupport::TestCase 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 From 8a8dd9fc92d94a1eb71dd83c247a55d48efd5fb2 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 03:39:23 -0700 Subject: [PATCH 5/9] Weigh the scope of a grace retry as the live refresh it repeats A retry within the grace window skipped scope validation along with the replays that get revoked. So a retry asking for more than the grant, or for a blank scope, got 200 and the successor where a live refresh gets invalid_scope. The response never widened the grant, but the answers disagreed. Only a replay that will be revoked skips the scope now, so no scope can steer it around its revocation. --- app/controllers/oauth/tokens_controller.rb | 12 ++++++++---- test/integration/oauth_refresh_replay_test.rb | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index f6522f2e14..56e463853e 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -31,10 +31,10 @@ class Oauth::TokensController < Oauth::BaseController before_action :set_identity end - # A rotated refresh token is answered as a retry or a replay before any - # scope is weighed, so no scope parameter can steer a replay around the - # revocation it earns. - before_action :set_refresh_scope, unless: -> { authorization_code_grant? || @retired_refresh_token } + # A replay outside the grace window is revoked before any scope is weighed, + # so no scope parameter can steer it around the revocation it earns. A retry + # inside the window is weighed like the live refresh it repeats. + before_action :set_refresh_scope, unless: -> { authorization_code_grant? || revocable_replay? } def create if authorization_code_grant? @@ -143,6 +143,10 @@ def validate_client_id end end + def revocable_replay? + @retired_refresh_token && !@retired_refresh_token.within_grace? + 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 diff --git a/test/integration/oauth_refresh_replay_test.rb b/test/integration/oauth_refresh_replay_test.rb index 8e509d8a21..88fb428105 100644 --- a/test/integration/oauth_refresh_replay_test.rb +++ b/test/integration/oauth_refresh_replay_test.rb @@ -94,6 +94,23 @@ class OauthRefreshReplayTest < ActionDispatch::IntegrationTest 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 successor rotated by a host whose clock runs behind still supersedes the retry" do ancestor = @grant.refresh_token refresh ancestor From 5286850653923189128b146b4a36f964f5093c5e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 04:14:07 -0700 Subject: [PATCH 6/9] Refuse a grace retry that asks for a different scope than its rotation produced A write grant rotated without a scope, then retried within the grace window with scope=read, got back the write-capable successor even though it asked for less. A retry repeats the rotation it lost the response to, so it must ask for what that rotation produced. Anything else gets invalid_scope. --- app/controllers/oauth/tokens_controller.rb | 13 +++++++++++- test/integration/oauth_refresh_replay_test.rb | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 56e463853e..22d28a4315 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -197,7 +197,7 @@ def authenticate_client def answer_refresh_replay(retired_refresh_token) if grant = Identity::AccessToken.find_by(id: retired_refresh_token.access_token_id) if retired_refresh_token.retryable?(grant) - render json: refresh_response(grant) + answer_refresh_retry grant elsif retired_refresh_token.within_grace? oauth_error "invalid_grant", "Refresh token superseded" else @@ -209,6 +209,17 @@ def answer_refresh_replay(retired_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 diff --git a/test/integration/oauth_refresh_replay_test.rb b/test/integration/oauth_refresh_replay_test.rb index 88fb428105..a5c9c0a8eb 100644 --- a/test/integration/oauth_refresh_replay_test.rb +++ b/test/integration/oauth_refresh_replay_test.rb @@ -111,6 +111,26 @@ class OauthRefreshReplayTest < ActionDispatch::IntegrationTest 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 From 0956b57aafc7f44fda25ee9b556fb2c3f9162ede Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 00:31:48 -0700 Subject: [PATCH 7/9] Refuse a superseded retry as superseded before weighing its scope Inside the grace window, a retired token whose successor has rotated can't be a retry, but its scope was still weighed first, so a bad scope answered invalid_scope where the replay deserved invalid_grant. Only a replay that can still be a retry is weighed now. --- app/controllers/oauth/tokens_controller.rb | 16 ++++++++++------ test/integration/oauth_refresh_replay_test.rb | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 22d28a4315..d7e7a42d56 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -31,10 +31,12 @@ class Oauth::TokensController < Oauth::BaseController before_action :set_identity end - # A replay outside the grace window is revoked before any scope is weighed, - # so no scope parameter can steer it around the revocation it earns. A retry - # inside the window is weighed like the live refresh it repeats. - before_action :set_refresh_scope, unless: -> { authorization_code_grant? || revocable_replay? } + # 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? @@ -143,8 +145,10 @@ def validate_client_id end end - def revocable_replay? - @retired_refresh_token && !@retired_refresh_token.within_grace? + # 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 diff --git a/test/integration/oauth_refresh_replay_test.rb b/test/integration/oauth_refresh_replay_test.rb index a5c9c0a8eb..66a28ef7ae 100644 --- a/test/integration/oauth_refresh_replay_test.rb +++ b/test/integration/oauth_refresh_replay_test.rb @@ -79,6 +79,25 @@ class OauthRefreshReplayTest < ActionDispatch::IntegrationTest 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 From 8fb89c98c274304e5b603dd40624e489f66b1844 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 25 Oct 2026 22:48:43 -0500 Subject: [PATCH 8/9] Redact retired refresh tokens from logged SQL A retired refresh token's row holds the token and the pair that succeeded it, so its statements are dropped from logs like the other credential tables'. --- lib/rails_ext/active_record_sql_redaction.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. From 07682a2c9f439d73eaff5234664a25f7b0d1f38e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 25 Oct 2026 23:49:09 -0500 Subject: [PATCH 9/9] Read a replayed refresh token's grant past the query cache The request may already have looked the grant up, and a cached read could answer a retry with the pair of a grant revoked since. --- app/controllers/oauth/tokens_controller.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 08ee345abd..11942ee4e4 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -198,9 +198,11 @@ def authenticate_client # 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. + # 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.find_by(id: retired_refresh_token.access_token_id) + 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?