Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/controllers/oauth/revocations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
76 changes: 69 additions & 7 deletions app/controllers/oauth/tokens_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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? }
Comment thread
jeremy marked this conversation as resolved.

def create
if authorization_code_grant?
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Comment thread
jeremy marked this conversation as resolved.
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,
Expand Down
27 changes: 23 additions & 4 deletions app/models/identity/access_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.

# 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 }
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions app/models/oauth/retired_refresh_token.rb
Original file line number Diff line number Diff line change
@@ -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
Comment thread
jeremy marked this conversation as resolved.

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
Comment thread
jeremy marked this conversation as resolved.
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
3 changes: 3 additions & 0 deletions config/recurring.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions db/migrate/20261006120000_create_oauth_retired_refresh_tokens.rb
Original file line number Diff line number Diff line change
@@ -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
Comment thread
jeremy marked this conversation as resolved.
t.datetime :created_at, null: false

t.index :refresh_token, unique: true
t.index :access_token_id
t.index :created_at
end
end
end
12 changes: 11 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion db/schema_sqlite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/rails_ext/active_record_sql_redaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions test/integration/oauth_flow_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading