Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
feaeb20
Sweep self-registered OAuth clients left without a grant for 30 days
jeremy Oct 6, 2026
1f4fcd5
Issue a code grant under the client's row lock, so the sweep can't ra…
jeremy Oct 6, 2026
cdd19bc
Record OAuth spec conformance and Basecamp parity
jeremy Oct 6, 2026
22adba2
Pin that a grant swept for lapsing restarts its client's retention pe…
jeremy Oct 6, 2026
9468f86
Bring the conformance record up to the rebased train
jeremy Oct 6, 2026
9761b65
Recheck the whole stale test under the lock, and count consent as use
jeremy Oct 6, 2026
22f0882
Repoint the conformance record at the restacked train
jeremy Oct 6, 2026
8c64eb9
Touch a grant's client after the grant commits, and destroy grants wi…
jeremy Oct 6, 2026
de690fc
Repoint the conformance record after the latest restack
jeremy Oct 6, 2026
9f8152d
Issue no code when consent's touch finds the client already swept
jeremy Oct 6, 2026
262cf85
Repoint the conformance record after the latest restack
jeremy Oct 6, 2026
5b47969
Carry the sweep past a client deleted after it was picked
jeremy Oct 6, 2026
5a708a4
Repoint the conformance record after the latest restack
jeremy Oct 6, 2026
2102637
Lock a client before its grants cascade on destroy
jeremy Oct 6, 2026
9147892
Repoint the conformance record after the latest restack
jeremy Oct 6, 2026
785f2a2
Repoint the conformance record after the latest restack
jeremy Oct 6, 2026
4a174de
Repoint the conformance record after the latest restack
jeremy Oct 6, 2026
a65010c
Record the PKCE verifier format and the PAT-management refusal
jeremy Oct 6, 2026
31a72a9
Reload a client's grants when locking it for destroy
jeremy Oct 6, 2026
8281bc2
Repoint the conformance record after the latest restack
jeremy Oct 6, 2026
fe680e4
Record the revocation throttle and the OAuth grant reach rule
jeremy Oct 6, 2026
7aa83c3
Repoint the conformance record, and add email changes to the grant re…
jeremy Oct 6, 2026
bf975a9
Record the invalid_client status policy in the conformance record
jeremy Oct 6, 2026
6ecbb8e
Record optional state, uncached OAuth responses and log filtering in …
jeremy Oct 17, 2026
73f0e8e
Say a concurrent rotation's loser gets the winner's pair, as #3160 ma…
jeremy Oct 17, 2026
1daa910
Record D6 and refresh narrowing as deliberate deviations in the ledger
jeremy Oct 17, 2026
a26edcd
Lock a client for destruction without reloading, so unsaved changes d…
jeremy Oct 17, 2026
eac532c
Run the dirty-client destroy pin: it sat below private, so Minitest s…
jeremy Oct 17, 2026
dda092f
Merge oauth-grant-idle-expiry: its migrations can be run again after …
jeremy Oct 25, 2026
ff13f84
Merge oauth-grant-idle-expiry
jeremy Oct 25, 2026
08f6aa1
Merge oauth-grant-idle-expiry into oauth-client-sweep
jeremy Oct 26, 2026
73fa952
Merge oauth-grant-idle-expiry into oauth-client-sweep
jeremy Oct 26, 2026
cfd17e3
Merge oauth-grant-idle-expiry into oauth-client-sweep
jeremy Oct 26, 2026
8e84bdc
Merge oauth-grant-idle-expiry into oauth-client-sweep
jeremy Oct 26, 2026
8558227
Merge oauth-grant-idle-expiry into oauth-client-sweep
jeremy Oct 26, 2026
7c2d31e
Merge oauth-grant-idle-expiry into oauth-client-sweep
jeremy Oct 26, 2026
48caaf9
Merge oauth-grant-idle-expiry into oauth-client-sweep
jeremy Oct 26, 2026
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
8 changes: 7 additions & 1 deletion app/controllers/oauth/authorizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ def new
def create
if denial?
redirect_to error_redirect_uri("access_denied", "User denied the request"), allow_other_host: true
else
# Consent is use, so it restarts a self-registered client's retention
# period: the sweep (Oauth::Client.cleanup) mustn't remove the client while
# the code it is about to receive is still redeemable. A touch that finds no
# row means the sweep got there first, and a code would be dead on arrival.
elsif @client.touch
Comment thread
jeremy marked this conversation as resolved.
code = Oauth::AuthorizationCode.generate \
client_id: @client.client_id,
identity_id: Current.identity.id,
Expand All @@ -46,6 +50,8 @@ def create
scope: Oauth.canonical_scope(params[:scope].presence || "read")

redirect_to success_redirect_uri(code), allow_other_host: true
else
oauth_error "invalid_request", "Unknown client"
end
end

Expand Down
9 changes: 6 additions & 3 deletions app/controllers/oauth/tokens_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def create
granted = Oauth.canonical_scope(@auth_code.scope)
permission = granted.split.include?("write") ? "write" : "read"

if access_token = @identity.access_tokens.redeem(@auth_code, oauth_client: @client, permission: permission)
if access_token = @client.redeem(@auth_code, identity: @identity, permission: permission)
render json: token_response(access_token, scope: granted)
else
oauth_error "invalid_grant", "Authorization code already used"
Expand All @@ -60,6 +60,8 @@ def create
else
oauth_error "invalid_grant", "Invalid refresh token"
end
rescue ActiveRecord::RecordNotFound
oauth_error "invalid_grant", "Unknown client"
end

private
Expand Down Expand Up @@ -138,11 +140,12 @@ def set_refreshable_access_token
end

# The code or refresh token must have been issued to the client_id in the
# request (RFC 6749 §4.1.3, §6).
# request (RFC 6749 §4.1.3, §6). A grant whose client has been swept away
# (Oauth::Client.cleanup) was issued to no client that still exists.
# One issued to another client gets the same answer as a dead one, so
# naming a public client tells nobody whether someone else's grant is live.
def validate_client_id
unless oauth_client_id == (@client || @access_token.oauth_client).client_id
unless oauth_client_id == (@client || @access_token.oauth_client)&.client_id
oauth_error "invalid_grant", authorization_code_grant? ? "Invalid or expired authorization code" : "Invalid refresh token"
end
end
Expand Down
11 changes: 11 additions & 0 deletions app/models/identity/access_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ class Identity::AccessToken < ApplicationRecord

before_create :set_expiry_and_refresh_token, if: :oauth_client_id?

# Issuing or destroying a grant restarts its client's retention period
# (Oauth::Client.cleanup). The touch waits until the grant's own transaction
# commits. A revocation locks the grant, and touching the client in the same
# transaction would then wait on the client's lock. A replayed code exchange
# holds that lock while it waits on the grant, so the two would deadlock.
after_commit :touch_oauth_client, on: %i[ create destroy ]
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.

class << self
# The token a bearer request may authenticate with: active and honored.
# Whether it permits the request's method is the caller's question.
Expand Down Expand Up @@ -113,6 +120,10 @@ def refresh(permission: self.permission)
end

private
def touch_oauth_client
Oauth::Client.where(id: oauth_client_id).touch_all if oauth_client_id?
end

def lock_grant
self.class.lock.where(id: id).pluck(:id)
end
Expand Down
50 changes: 49 additions & 1 deletion app/models/oauth/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,20 @@ class Oauth::Client < ApplicationRecord
SECRET_AUTH_METHODS = %w[ client_secret_post client_secret_basic ]
AUTH_METHODS = %w[ none ] + SECRET_AUTH_METHODS

has_many :access_tokens, class_name: "Identity::AccessToken", foreign_key: :oauth_client_id, inverse_of: :oauth_client
# Registration is open, and MCP clients often register afresh on every
# install or reconnect, so a self-registered client left without a grant
# this long is abandoned (RFC 7591 §5).
UNUSED_RETENTION = 30.days

has_many :access_tokens, class_name: "Identity::AccessToken", foreign_key: :oauth_client_id, inverse_of: :oauth_client, dependent: :destroy
Comment thread
jeremy marked this conversation as resolved.

has_secure_token :client_id, length: 32

# A code exchange locks the client and then issues a grant (#redeem).
# Destroying a client takes the same lock before its grants cascade, in the
# same order, so no grant can land after the cascade and be stranded.
before_destroy :lock_client, prepend: true
Comment thread
jeremy marked this conversation as resolved.

validates :name, presence: true, length: { maximum: 255 }
validates :client_id, uniqueness: true, allow_nil: true
# Registration is open and unauthenticated, so what it stores is bounded:
Expand All @@ -27,6 +37,36 @@ class Oauth::Client < ApplicationRecord
scope :trusted, -> { where trusted: true }
scope :dynamically_registered, -> { where dynamically_registered: true }

# A grant touches its client when it is created or destroyed, so updated_at
# is the last grant activity: an app the user disconnected yesterday is not
# swept for having registered long ago.
scope :stale, -> { dynamically_registered.where.missing(:access_tokens).where(updated_at: ...UNUSED_RETENTION.ago) }

def self.cleanup
stale.find_each(&:destroy_if_still_unused)
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
end

# The sweep and #redeem take the same row lock, so a grant either lands
# before the recheck, which then spares the client, or finds the client
# already gone and is never issued. There is no foreign key to refuse a
# token for a deleted client. The recheck is the whole stale test, not just
# the absence of tokens: a grant issued and revoked since the sweep picked
# this client has touched it, and restarted its retention period.
def destroy_if_still_unused
with_lock do
Comment thread
jeremy marked this conversation as resolved.
destroy if self.class.stale.exists?(id)
end
rescue ActiveRecord::RecordNotFound
# Already gone, deleted since the sweep picked it.
end

# Raises ActiveRecord::RecordNotFound if the client has been swept since it
# was read.
def redeem(authorization_code, identity:, permission:)
with_lock do
Comment thread
jeremy marked this conversation as resolved.
identity.access_tokens.redeem authorization_code, oauth_client: self, permission: permission
end
end

def confidential?
token_endpoint_auth_method.in?(SECRET_AUTH_METHODS)
Expand Down Expand Up @@ -54,6 +94,14 @@ def allows_scope?(requested_scope)
end

private
# Takes the row lock without reloading, since lock! refuses a record with
# unsaved changes and destroy otherwise ignores them. Resetting the grants
# makes the cascade see every grant committed before the lock.
def lock_client
self.class.lock.where(id: id).pluck(:id)
access_tokens.reset
end

def generate_client_secret
self.client_secret ||= self.class.generate_unique_secure_token
end
Expand Down
3 changes: 3 additions & 0 deletions config/recurring.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ production: &production
cleanup_imports:
command: "Account::Import.cleanup"
schedule: every hour at minute 25
cleanup_oauth_clients:
command: "Oauth::Client.cleanup"
schedule: every day at 04:12
incineration:
class: "Account::IncinerateDueJob"
schedule: every 8 hours at minute 16
Expand Down
Loading
Loading