Skip to content

Revoke the grant when a rotated refresh token is replayed, with a 60s retry grace - #3160

Merged
jeremy merged 16 commits into
mainfrom
oauth-refresh-replay
Oct 27, 2026
Merged

jeremy merged 16 commits into
mainfrom
oauth-refresh-replay

Conversation

@jeremy

@jeremy jeremy commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Stacked on #3082 (train: #2296 → #3080 → #3081 → #3082 → this → #3161 → #3159 → #3163). Implements D1: refresh replay detection of the Fizzy OAuth conformance decisions. It is a new PR, not a commit on #3081, because #3081 is converged and adding to it would mean rebasing #3082, #3158 and #3159. The "Deliberately not done: refresh-token reuse detection" line in #3081's body is superseded by this PR.

Problem

Since #3081, each refresh rotates the grant's refresh token in place, and the old token is forgotten. A rotated token that came back failed as unknown with invalid_grant. Nothing else happened. If an attacker stole a refresh token and refreshed first, the attacker owned the grant. The legitimate client's next refresh failed quietly, and nothing revoked the thief's copy. OAuth 2.1 §4.3.1 and RFC 9700 §4.14.2 call for rotation with replay detection: a rotated token presented again means two parties hold the grant, so the server revokes it.

Change (1b0c206, then 677752a and e783576; 49ba964 locks the grant before its retired tokens are deleted, so destroy and rotation take locks in the same order). Each retired row records the successor its rotation issued. A retry is answerable only while that successor is still the grant's current refresh token, judged against a single read of the grant, so the check depends neither on clock order nor on a second query.

Each rotation retires the presented token into oauth_retired_refresh_tokens. A retired token presented by the grant's own client is answered as follows:

When Answer bc3
Within 60s of its rotation, and the successor it produced is still current 200 with that same successor pair (same access token, same refresh token). Nothing rotates. rotate! grace branch, within_grace_window?
Within 60s, but the successor has itself rotated invalid_grant "Refresh token superseded", no revocation. The client demonstrably holds the live descendant. successor_for_retry!
60s or more after its rotation, any ancestor Grant revoked, successor included; invalid_grant revoke_family!(reason: "refresh_reuse")
A refresh that loses a concurrent rotation Answered as a grace retry, so it gets the winner's successor lost-race branch
Presented under another client's client_id invalid_grant as before, no revocation. The destructive branch is reachable only by the grant's own client. find_presented! checks the client before rotation
  • The window is 60s, and it is a constant. bc3's per-client refresh_replay_grace defaults to 60 (README). Fizzy has no per-client TTL columns, so it uses that default. The boundary matches bc3's strict rotated_at > grace.ago (L38): 59s retries, and exactly 60s revokes.
  • The replay is judged before the requested scope. Otherwise a replay asking for a scope outside the grant would get invalid_scope and skip the revocation.
  • Revocation (RFC 7009): presenting a rotated refresh token at /oauth/revocation revokes its grant, as bc3's revocation of any family member does. The ownership check from Support confidential clients with client_secret_post #3082 still applies.
  • Retention: retired tokens are swept daily after 90 days (Oauth::RetiredRefreshToken.cleanup in config/recurring.yml). That matches bc3's 90-day refresh_token_ttl, after which a rotated bc3 token has expired too. Revoking a grant deletes its retired tokens.

Accepted trade-off (same as bc3)

For 60s after a rotation, anyone holding the just-rotated token who can name the client gets the successor pair back. For a public client, naming the client takes only its client_id. This is the cost of the grace window D1 asks for: without it, a client that lost a response would be revoked as a thief. Once the window closes, the same presentation revokes the grant.

Tests (written first; 6 of 10 integration tests failed before the change, and the other 4 guard against over-revoking)

  • test/integration/oauth_refresh_replay_test.rb: same successor at 59s, revoke at exactly 60s, revoke after a day, an older ancestor revokes, superseded refused without revoking, another client's replay doesn't revoke, a lost race serves the winner, an out-of-grant scope still revokes, and revoking through a rotated token works but only for its client.
  • Also: a successor rotated by a host whose clock runs behind still supersedes the retry, and a grant deleted mid-request answers invalid_grant, not a 500.
  • test/models/oauth/retired_refresh_token_test.rb: rotation retires the token, the race loser retires nothing and the winner's successor stays retryable, revocation deletes retired tokens, and cleanup respects retention.
  • Mutation checks: >= at the boundary fails the 60s test, a window 2s shorter fails the 59s test, dropping !superseded? fails the superseded test, and removing the scope guard fails the scope test.
  • oauth_flow_test "refresh grant invalidates the previous refresh token" now replays after the grace window.

Locally: bin/rails test test/integration test/models test/controllers passes on SQLite (1531 tests). The MySQL schema.rb entry was written by hand to match the dumper's sorted format, and CI's MySQL jobs load it.

Copilot AI balanced review requested due to automatic review settings October 6, 2026 07:28
@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-26T05:16:28.066133Z 07682a2 Manual request
🔒 Security Review ✅ Completed 2026-10-26T05:18:05.144477Z 07682a2 Manual request

Security findings

Advisory findings (1)

ℹ️ 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.

@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Decision implemented here: D1 (refresh replay detection). A rotated refresh token presented again revokes the grant's whole token family. The exception is a 60s grace window: inside it, a retry of the rotation that just happened gets the same successor pair back. That is bc3's refresh_replay_grace default and its rotated_at > grace.ago boundary. The PR body cites bc3 for each case.

@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: 51a35cf0bb

ℹ️ 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/retired_refresh_token.rb
Comment thread app/controllers/oauth/tokens_controller.rb Outdated

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.

@jeremy
jeremy force-pushed the oauth-refresh-replay branch from 51a35cf to 51fe3ff Compare October 6, 2026 07:39
@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 51fe3ff0e4

ℹ️ 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
jeremy force-pushed the oauth-refresh-replay branch from 51fe3ff to 677752a Compare October 6, 2026 07:55
@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: 677752a782

ℹ️ 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/tokens_controller.rb Outdated
Comment thread app/models/oauth/retired_refresh_token.rb Outdated
@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: e783576920

ℹ️ 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 commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 49ba96436d

ℹ️ 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

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 49ba96436d

ℹ️ 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

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Convergence summary (D1)

Head 49ba96436. MERGEABLE, 0 unresolved threads. CI is green apart from the train-wide Security gem audit (rubyzip 3.3.0; see the #3082 summary).

Fixed and resolved: check/read atomicity, grant deleted mid-replay, clock-order supersession (now causal, via successor_refresh_token), destroy/rotate lock order.

Declined and resolved: keep retired tokens beyond 90 days. This matches bc3's refresh TTL plus cleanup, a token that old is dead either way, and #3161 bounds grant idleness at the same limit.

Not re-reviewed: Codex last reviewed 677752a78 (before the restack, 51fe3ff0e). It hasn't come back for e78357692 or 49ba96436 after a @codex review request.

@chatgpt-codex-connector

Copy link
Copy Markdown

Review Result

No additional issues found at head 49ba96436.

The convergence summary’s “not re-reviewed” note is now stale: the supplied PR comments show subsequent Codex reviews of 49ba96436d, both with no major findings.

The final implementation addresses the previously raised concerns:

No code changes were necessary, so I did not create a commit or a new pull request.

Testing

  • ✅ git diff --check 69b77e7c51ff1a2be3c64cee45ef9f56f78a1211..HEAD
  • ⚠️ bin/rails test test/integration/oauth_refresh_replay_test.rb test/models/oauth/retired_refresh_token_test.rb test/integration/oauth_flow_test.rb (environment limitation: the repository requires mise 2026.8.10, while the installed version is 2026.4.28)
  • ✅ git status --short --branch (clean working tree)

View task →

jeremy added a commit that referenced this pull request Oct 6, 2026
D1 through D4 are decided and built: replay detection (#3160), idle expiry
(#3161), client_secret_basic and client authentication at revocation (#3082).
The record now cites those PRs, the narrower Basic challenge, the stricter
refusal of a client secret that authenticates no client, and the restored
revocation of personal access tokens. Every SHA now points at the train as
rebased onto main.
@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-refresh-replay branch from 49ba964 to 0267b88 Compare October 6, 2026 10:21
@jeremy
jeremy force-pushed the oauth-refresh-replay branch from 0267b88 to b361a77 Compare October 6, 2026 10:28
@jeremy
jeremy force-pushed the oauth-confidential-clients branch from d071ad7 to 8947b42 Compare October 6, 2026 10:28

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

Security-sensitive replay handling and cross-database concurrency, particularly MySQL lock ordering, warrant final human validation.

No open issues 🎉

Review effort: Balanced

jeremy added a commit that referenced this pull request Oct 17, 2026
* commit '0956b57aafc7f44fda25ee9b556fb2c3f9162ede':
  Refuse a superseded retry as superseded before weighing its scope
  Refuse a grace retry that asks for a different scope than its rotation produced
  Weigh the scope of a grace retry as the live refresh it repeats
  Lock a grant before deleting its retired tokens
  Judge supersession by the successor a rotation produced, not by timestamps
  Read a grace retry's successor before checking that it's still current
  Revoke the grant when a rotated refresh token is replayed, with a retry grace
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
  ...
…topping partway

* origin/oauth-bearer-challenge:
  Make the OAuth migrations safe to run again after stopping partway

@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: 3ec68226bc

ℹ️ 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/tokens_controller.rb
* origin/oauth-bearer-challenge:
  Answer 404 from a dark endpoint however many times it's asked
* tp/oauth-bearer-challenge: (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-bearer-challenge:
  Bring main's tests up to this branch: Ruby 4 and the access token finder
* tp/oauth-bearer-challenge:
  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

@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: d5cf7ecbdd

ℹ️ 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 db/migrate/20261006120000_create_oauth_retired_refresh_tokens.rb
* tp/oauth-bearer-challenge:
  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
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'.
@jeremy

jeremy commented Oct 26, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 8fb89c98c2

ℹ️ 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".

@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 Security Review · Automatically triggered

Here are some automated security review suggestions for this pull request.

Reviewed commit: 8fb89c98c2

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Comment thread app/controllers/oauth/tokens_controller.rb
* tp/oauth-bearer-challenge:
  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: 4e397adb3a

ℹ️ 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/tokens_controller.rb Outdated
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.
@jeremy

jeremy commented Oct 26, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 07682a2c9f

ℹ️ 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".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 07682a2c9f

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Base automatically changed from oauth-bearer-challenge to main October 27, 2026 06:27
@jeremy
jeremy merged commit 0338296 into main Oct 27, 2026
15 of 17 checks passed
@jeremy
jeremy deleted the oauth-refresh-replay branch October 27, 2026 06:27
kheubaum pushed a commit to kheubaum/fizzy that referenced this pull request Oct 27, 2026
D1 through D4 are decided and built: replay detection (basecamp#3160), idle expiry
(basecamp#3161), client_secret_basic and client authentication at revocation (basecamp#3082).
The record now cites those PRs, the narrower Basic challenge, the stricter
refusal of a client secret that authenticates no client, and the restored
revocation of personal access tokens. Every SHA now points at the train as
rebased onto main.
kheubaum pushed a commit to kheubaum/fizzy that referenced this pull request Oct 27, 2026
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