Skip to content

Sweep self-registered OAuth clients left without a grant for 30 days - #3159

Merged
jeremy merged 37 commits into
mainfrom
oauth-client-sweep
Oct 27, 2026
Merged

jeremy merged 37 commits into
mainfrom
oauth-client-sweep

Conversation

@jeremy

@jeremy jeremy commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Stacked on #3161 (oauth-grant-idle-expiry): #2296 → #3080 → #3081 → #3082 → #3160 → #3161 → #3159 → #3163. It moved there from #3082 on 2026-10-06 so that its overlap with #3160 and #3161 (the tokens controller, Identity::AccessToken associations and grant destruction) is settled in this PR instead of at merge time. See the restack comment for details.

Client registration is open and unauthenticated. Its only brake is 10 registrations per minute per IP. Nothing ever deletes an oauth_clients row, so abandoned registrations only accumulate, and MCP clients often re-register on every install or reconnect (RFC 7591 §5 asks the server to mitigate this). bc3's Oauth::CleanupJob#cleanup_unused_dcr_clients sweeps never-authorized DCR clients after 30 days. This PR adds the same sweep to Fizzy.

What changes

  • Daily sweep: config/recurring.yml runs Oauth::Client.cleanup every day at 04:12. It destroys dynamically registered clients that hold no access token and have had no grant activity for UNUSED_RETENTION (30 days). Operator-provisioned clients (dynamically_registered: false) are never swept.
  • The clock starts at the last grant, not at registration: a grant touches its client after it is created or destroyed (after_commit, not belongs_to touch: true, so no transaction holds a grant while waiting on its client, which used to deadlock against a replayed code exchange). Consent touches the client too, so a client isn't swept while its 60-second code is live. Fizzy has no record of whether a client was ever authorized. Without the touch, an app registered 60 days ago that the user disconnected yesterday in Connected Apps would be swept tonight. On reconnect it would then hit "Unknown client" at authorize. Refresh rotation uses update_all and doesn't touch, so steady use adds no writes.
  • The sweep and issuance share one lock: destroy_if_still_unused locks the client row and reruns the full stale test (tokens and timestamp) before destroying it, like bc3's obliterate_if_still_unauthorized. The code exchange now issues through Oauth::Client#redeem, which takes the same row lock. There's no foreign key to stop it, so before this a token could commit between the sweep's recheck and its delete. Now the grant either lands before the recheck, which then spares the client, or finds the client already gone and gets invalid_grant "Unknown client". Codex caught this in review, and it was fixed in fa904e5.
  • has_many :access_tokens, dependent: :destroy: a manual client destroy can't strand tokens, and each grant's own cascade runs, so retired refresh tokens from Revoke the grant when a rotated refresh token is replayed, with a 60s retry grace #3160 go with it.
  • Missing client rows: the refresh grant now answers a token whose client row is gone (for example after a manual delete) with invalid_grant instead of NoMethodError (a 500). The code grant already answered a missing client with invalid_grant.

Tests

  • On Lapse OAuth grants left idle for 90 days #3161: when idle expiry deletes a lapsed grant, the deletion touches the client too, so the client gets a full 30 days before this sweep removes it ("Pin that a grant swept for lapsing restarts its client's retention period"). Removing touch: true makes this test fail.
    I wrote the tests first and watched each one fail before its change. I mutation-checked three mechanisms, and each mutation fails its test:
  • removing touch: true
  • making the recheck unconditional
  • issuing without the row lock

The race test deletes the client between the exchange reading it and issuing the token.

The full OSS SQLite suite passes locally: 1802 tests, 0 failures, 1 skip. Rubocop is clean.

Fixed underneath, in #2296 ("Point a client's access_tokens at oauth_client_id, and drop the stray foreign key", now 65e4d3e)

  • Oauth::Client#access_tokens was broken: it inferred client_id as the foreign key, a column identity_access_tokens doesn't have. Nothing used the association until this sweep.
  • Stray foreign key in db/schema.rb: it declared add_foreign_key "identity_access_tokens", "oauth_clients", but the AddOauth migration says foreign_key: false and db/schema_sqlite.rb has no such key. So databases built by running migrations had no foreign key, while MySQL databases loaded from the schema (CI and fresh installs) had one.

Not done, on purpose

  • RFC 7592 management, initial access tokens, host vetting: the hosted plan keeps registration open for Cursor-style clients. Host vetting belongs with CIMD in phase C.
  • Global registration cap: one actor's abuse would become a registration outage for every legitimate client.
  • 24-hour retention: a client can register and authorize days later from a cached client_id. 30 days matches bc3.

Behaviour to know

  • A self-registered client whose user disconnected it and that then sees no new grant for 30 days is swept. If the app comes back with its cached client_id it gets "Unknown client" and has to register again. bc3 sweeps on "never authorized", and Fizzy can only approximate that with "no grant activity for 30 days".

invalid_client status policy: the conformance record now reflects #3082 b6b83296a. A failed Basic attempt gets 401 + Basic; any other invalid_client gets 400. The shipped-client matrix is in #3082: no first-party Fizzy client calls /oauth/*.

Copilot AI balanced review requested due to automatic review settings October 6, 2026 04:42
@jeremy jeremy mentioned this pull request Oct 6, 2026
4 tasks
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-26T04:36:17.555926Z 7c2d31e New commits
🔒 Security Review ✅ Completed 2026-10-26T04:34:59.463951Z 7c2d31e New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d26a9ac74d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/models/oauth/client.rb Outdated
Comment thread app/models/oauth/client.rb Outdated
jeremy added a commit that referenced this pull request Oct 6, 2026
Requirement-by-requirement status for the OAuth train (#2296, #3080,
#3081, #3082) and the PRs above it (#3158, #3159): what conforms, which
commit fixed what, what the hosted plan schedules, the deliberate
postures, and the decisions still open.
@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Added docs/oauth/spec_conformance.md (21c86ba). It records the spec-by-spec conformance and bc3-parity status for the whole OAuth train, plus this PR and #3158, and ends with six open decisions (D1–D6): refresh replay detection, refresh idle expiry, client_secret_basic, revocation client auth (the open thread on #3082), consent for self-registered clients, and the no-credentials 401 rule in #3158. This is a docs-only change.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 21c86ba45b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/models/oauth/client.rb
jeremy added a commit that referenced this pull request Oct 6, 2026
Requirement-by-requirement status for the OAuth train (#2296, #3080,
#3081, #3082) and the PRs above it (#3158, #3159): what conforms, which
commit fixed what, what the hosted plan schedules, the deliberate
postures, and the decisions still open.
@jeremy
jeremy force-pushed the oauth-confidential-clients branch from 69b77e7 to d071ad7 Compare October 6, 2026 10:21
@jeremy
jeremy force-pushed the oauth-client-sweep branch from 21c86ba to c6401f3 Compare October 6, 2026 10:21
@jeremy
jeremy changed the base branch from oauth-confidential-clients to oauth-grant-idle-expiry October 6, 2026 10:21

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c6401f341e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/models/oauth/client.rb
Comment thread app/models/oauth/client.rb Outdated
@jeremy
jeremy force-pushed the oauth-client-sweep branch from c6401f3 to afc9599 Compare October 6, 2026 10:28
@jeremy
jeremy force-pushed the oauth-grant-idle-expiry branch from 9d25c0a to 5e40b3d Compare October 6, 2026 10:28

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: afc9599f95

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/controllers/oauth/authorizations_controller.rb Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fc3f2dac92

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/models/identity/access_token.rb
@jeremy
jeremy force-pushed the oauth-client-sweep branch from fc3f2da to 98e7112 Compare October 6, 2026 10:41
@jeremy
jeremy force-pushed the oauth-grant-idle-expiry branch from cf6a208 to 332c2da Compare October 6, 2026 10:41

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 98e711283a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/models/oauth/client.rb
@jeremy
jeremy force-pushed the oauth-client-sweep branch from 98e7112 to 9ec1432 Compare October 6, 2026 10:49
@jeremy
jeremy force-pushed the oauth-grant-idle-expiry branch from 332c2da to fde6755 Compare October 6, 2026 10:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The post-commit retention update allows cleanup to delete a freshly disconnected client prematurely.

1 open issue

Review effort: Balanced

Comment thread app/models/identity/access_token.rb

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Destructive cleanup and client/grant lock ordering need human validation under concurrent MySQL workloads.

1 open issue

Review effort: Balanced

jeremy added a commit that referenced this pull request Oct 17, 2026
#3159's lock_client no longer reloads the row, so the guard reads the
client_id in the database rather than one changed in memory but not
saved.
jeremy added a commit that referenced this pull request Oct 17, 2026
#3159's lock_client no longer reloads the row, so the guard reads the
client_id in the database rather than one changed in memory but not
saved.
@jeremy
jeremy requested a balanced review from Copilot October 17, 2026 10:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Destructive OAuth cleanup and cross-model locking warrant human validation of concurrency behavior across supported databases.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@jeremy

jeremy commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: eac532ca82

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

jeremy added a commit that referenced this pull request Oct 17, 2026
#3159's lock_client no longer reloads the row, so the guard reads the
client_id in the database rather than one changed in memory but not
saved.
jeremy added a commit that referenced this pull request Oct 17, 2026
#3159's lock_client no longer reloads the row, so the guard reads the
client_id in the database rather than one changed in memory but not
saved.
jeremy added a commit that referenced this pull request Oct 17, 2026
* commit 'eac532ca82a4a6b77555099bb32f255c5e6fd126': (28 commits)
  Run the dirty-client destroy pin: it sat below private, so Minitest skipped it
  Lock a client for destruction without reloading, so unsaved changes don't stop it
  Record D6 and refresh narrowing as deliberate deviations in the ledger
  Say a concurrent rotation's loser gets the winner's pair, as #3160 made it
  Record optional state, uncached OAuth responses and log filtering in the ledger
  Record the invalid_client status policy in the conformance record
  Repoint the conformance record, and add email changes to the grant reach rule
  Record the revocation throttle and the OAuth grant reach rule
  Repoint the conformance record after the latest restack
  Reload a client's grants when locking it for destroy
  Record the PKCE verifier format and the PAT-management refusal
  Repoint the conformance record after the latest restack
  Repoint the conformance record after the latest restack
  Repoint the conformance record after the latest restack
  Lock a client before its grants cascade on destroy
  Repoint the conformance record after the latest restack
  Carry the sweep past a client deleted after it was picked
  Repoint the conformance record after the latest restack
  Issue no code when consent's touch finds the client already swept
  Repoint the conformance record after the latest restack
  ...
jeremy added a commit that referenced this pull request Oct 17, 2026
#3159's lock_client no longer reloads the row, so the guard reads the
client_id in the database rather than one changed in memory but not
saved.
jeremy added a commit that referenced this pull request Oct 18, 2026
#3159's lock_client no longer reloads the row, so the guard reads the
client_id in the database rather than one changed in memory but not
saved.
…stopping partway

* origin/oauth-grant-idle-expiry:
  Make the OAuth migrations safe to run again after stopping partway
* origin/oauth-grant-idle-expiry:
  Answer 404 from a dark endpoint however many times it's asked
* tp/oauth-grant-idle-expiry: (60 commits)
  Name v0.2.0 in the pin's comment
  Pin fizzy-mcp-server v0.2.0
  Refuse a join code that was reset while it was being redeemed
  Filter presigned storage signatures from logs
  Keep the reviewer's memberships in reach, and print the member audit escaped
  Import an export that lacks a column this database has only just gained
  List the reviewer accounts' members when signing the reviewer out
  Bump thruster from 0.1.26 to 0.1.27 (#3199)
  Give the asset build a placeholder direct-upload credential
  Anchor the stubbed storage host pattern
  Sign direct uploads with a write-only storage credential
  Lock the reviewer's email address, so a code holder can't move its membership out of revocation's reach
  Use Ruby's GC defaults and log job memory (#3238)
  Pin the fizzy_mcp accessory to fizzy-mcp-server v0.1.0 by digest
  Scope webhook dispatch by account as well as board
  Test the reviewer audit against the structured log line production writes
  Revoke on the email alone, and record each use in the request's structured log
  Remove the reviewer's access tokens and passkeys on sign-out, so a code holder can't keep a way back in
  Wire the reviewer sign-in into production from Deploy/Fizzy
  Close the reviewer's live sockets on sign-out, and keep the identity id out of Sentry
  ...
* tp/oauth-grant-idle-expiry:
  Bring main's tests up to this branch: Ruby 4 and the access token finder
* tp/oauth-grant-idle-expiry:
  Pin the load balancers to kamal-proxy v0.10.2, and say why configure-lb-mcp.sh still checks
  Give the load balancer the real client IP
* tp/oauth-grant-idle-expiry:
  Bring main's SQL redaction tests up to bearer lookup by find_honored
  Redact OAuth client statements from logged SQL, now that a client has a secret
* tp/oauth-grant-idle-expiry:
  Redact retired refresh tokens from logged SQL
* tp/oauth-grant-idle-expiry:
  Filter the code parameter by its whole name, so code_challenge stays readable

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7c2d31eecb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/controllers/oauth/authorizations_controller.rb
* tp/oauth-grant-idle-expiry:
  Read a replayed refresh token's grant past the query cache
Base automatically changed from oauth-grant-idle-expiry to main October 27, 2026 06:27
@jeremy
jeremy merged commit bc0a593 into main Oct 27, 2026
14 checks passed
@jeremy
jeremy deleted the oauth-client-sweep branch October 27, 2026 06:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants