Skip to content

Lapse OAuth grants left idle for 90 days - #3161

Merged
jeremy merged 15 commits into
mainfrom
oauth-grant-idle-expiry
Oct 27, 2026
Merged

jeremy merged 15 commits into
mainfrom
oauth-grant-idle-expiry

Conversation

@jeremy

@jeremy jeremy commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Stacked on #3160 (train: #2296 → #3080 → #3081 → #3082 → #3160 → this → #3159 → #3163). Implements D2: idle expiry of the Fizzy OAuth conformance decisions. It is a new PR for the same reason as #3160: #3081 owns expiry but is converged, and reopening it would rebase the train. The "Deliberately not done: refresh-token expiry" line in #3081's body is superseded here.

Problem

A Fizzy OAuth grant lived until someone revoked it. A refresh token left in an abandoned client stayed good forever.

What bc3 does

bc3 has no separate idle timer. Every rotation mints a refresh token with expires_at: client.refresh_token_ttl.seconds.from_now (refresh_token.rb L80, L113), and the TTL is 90 days. So "idle" means no refresh for 90 days, and every refresh restarts the clock. Using an access token doesn't extend anything. A token is expired once expires_at < Time.current, and the token endpoint then refuses it with invalid_grant (L148).

Change (3c78975, 21f8ada, 8aafeae, cf33269)

  • The idle clock is the grant's updated_at. Only issuance and rotation write a grant, and every rotation sets updated_at, including rotations by code from before this PR. A grant lapses once updated_at + 90 days (Identity::AccessToken::REFRESH_IDLE_LIMIT) has passed. The first version stored a refresh_token_expires_at column. Codex found two ways it could fall out of step during a rolling deploy: a NULL written by old code, and a stale backfilled deadline left behind when old code rotated. 8aafeae dropped the column, so there is no migration. The trade-off: any future code that writes to a grant row also restarts its idle clock, and the constant's comment says so.
  • A refresh on a lapsed grant answers invalid_grant "Refresh token expired". The client has to restart authorization, and the user re-consents, since Fizzy's consent screen always shows. This check runs after the client check and does not apply to rotated tokens. Those still go through Revoke the grant when a rotated refresh token is replayed, with a 60s retry grace #3160's replay handling, so a replay of a lapsed grant's old token revokes it, as bc3's rotated-and-expired branch does.
  • Rotation itself matches only unlapsed grants, so a refresh already in flight can't renew a grant past its deadline. The sweep also locks and rechecks each grant before deleting it, so a grant renewed after selection is spared, even one renewed by pre-idle-expiry code mid-deploy.
  • Lapsed grants drop out of Connected Apps and out of the profile page's entry point. A daily sweep (Identity::AccessToken.cleanup, config/recurring.yml) deletes them along with their retired tokens. bc3 keeps expired refresh rows 7 more days for debugging (cleanup_job.rb). Fizzy has no audit consumer for them, so it deletes them at once.
  • Oauth::RetiredRefreshToken::RETENTION now references the same limit.

Tests (written first; each failed before the change it pins)

test/integration/oauth_grant_idle_expiry_test.rb:

  • a refresh at exactly 90 days works, and one at 90 days + 1s lapses as invalid_grant
  • a refresh at day 89 restarts the clock, so day 178 still refreshes
  • a lapsed grant leaves Connected Apps
  • the sweep deletes lapsed grants and keeps live grants and PATs
  • rotation refuses a grant that is already past its deadline
  • the sweep spares a grant renewed after it was selected
  • a rotation by older code (which writes only the token and updated_at) restarts the clock
  • the profile page's Connected Apps link disappears once every grant has lapsed

Mutation check: removing the unlapsed filter from the profile page fails its test.

The tests move the clock with travel_to Time.current + d, not travel d. travel adds calendar days in the machine's local zone, so across a DST change it lands an hour off the model's UTC arithmetic. That flipped the exact-boundary test locally.

Locally: bin/rails test test/integration test/models test/controllers passes on SQLite (1540 tests). This PR makes no schema change.

Copilot AI balanced review requested due to automatic review settings October 6, 2026 07:29
@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:35:45.433283Z — New commits
🔒 Security Review ✅ Completed 2026-10-26T04:34:28.891566Z c4617f7 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.

@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Decision implemented here: D2 (idle expiry). A grant that goes 90 days without a refresh lapses, and the client has to send the user back through consent. As in bc3, idle means time since the last refresh: each rotation restarts the 90-day refresh_token_ttl, and access-token use doesn't extend it.

@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: 842cdcd17c

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

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 force-pushed the oauth-grant-idle-expiry branch from 842cdcd to a620cfa Compare October 6, 2026 07:41
@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: a620cfa8c0

ℹ️ 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
Comment thread app/controllers/my/connected_apps_controller.rb
@jeremy
jeremy force-pushed the oauth-refresh-replay branch from 51fe3ff to 677752a Compare October 6, 2026 07:55
@jeremy
jeremy force-pushed the oauth-grant-idle-expiry branch from 1177afb to 1938e05 Compare October 6, 2026 07:55
@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: 1938e053bf

ℹ️ 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-grant-idle-expiry branch from 1938e05 to 8535922 Compare October 6, 2026 08:11
@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. Chef's kiss.

Reviewed commit: 853592259c

ℹ️ 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-grant-idle-expiry branch from 8535922 to 8aafeae Compare October 6, 2026 08:27
@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: 8aafeae3eb

ℹ️ 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 Outdated
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. Can't wait for the next one!

Reviewed commit: cf33269f37

ℹ️ 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 (D2)

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

Design change in review: the idle clock moved from a stored refresh_token_expires_at column to updated_at (8aafeae3e). This PR now has no migration.

Fixed and resolved: atomic lapse at rotation, NULL backfill during rollout, stale deadline after an old-code rotation, profile entry point, sweep recheck under lock.

Declined and resolved: use the database clock in the rotation predicate (the skew is bounded by one request's latency at the deadline, and the worst case is the re-consent that lapsing means anyway) and index updated_at for the sweep (it would add a write on every hourly rotation to save a daily scan of a small table).

Not re-reviewed: Codex last reviewed 8aafeae3e. It hasn't come back for cf33269f3 after a @codex review request.

@chatgpt-codex-connector

Copy link
Copy Markdown

Review Result

No additional issues found at cf33269f375f372e427a1ba7984ea85ab5db7071.

The final implementation consistently:

The worktree remains clean, so there were no changes to commit and no follow-up pull request was created.

Testing

  • ✅ git diff --check
  • ✅ git status --porcelain — clean worktree
  • ⚠️ bin/rails test test/integration/oauth_grant_idle_expiry_test.rb — could not start because the environment has mise 2026.4.28, while .mise.toml requires 2026.8.10.

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-grant-idle-expiry branch from cf33269 to 9d25c0a 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
Copilot AI previously approved these changes Oct 17, 2026

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.

🟢 Approved

Static review identified no unresolved blocking issues; regression coverage addresses the key boundaries and races, but tests were not executed.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

@jeremy

jeremy commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

Review threads: 2 resolved (1 fixed, 1 declined with the reasoning in the thread).
Declined: refresh race at the idle deadline — the loser re-authorizes, the same outcome a second later.

Before this, a grant lived until it was revoked: a refresh token left on a
laptop that was never opened again stayed good indefinitely. bc3 handles
this through its refresh tokens. Each rotation mints a token that is good
for refresh_token_ttl (90 days), so a grant lapses once it has gone 90 days
without a refresh. This applies the same rule to Fizzy grants.

- Each grant carries refresh_token_expires_at. It is set 90 days out when
  the grant is issued and again on every rotation. As in bc3, idle means no
  refresh: using the access token doesn't extend the grant, because that
  token expires an hour after the last refresh anyway.
- Refreshing a lapsed grant answers invalid_grant. The client has to send
  the user through authorization again, and the consent screen always shows.
- Lapsed grants drop out of Connected Apps, and a daily sweep deletes them.
- The boundary matches bc3's Oauth::RefreshToken#expired?
  (expires_at < now). A refresh at exactly 90 days works. One second later,
  it doesn't.
- Retired refresh tokens are kept for the same window, since a lapsed
  grant leaves no replay to catch.

Existing OAuth grants are backfilled to lapse 90 days after their last
rotation.
…en without an expiry

Rotation now matches only unlapsed grants. A refresh that passed the lapse
check just before the deadline can no longer renew the grant after it, and
the daily sweep can no longer delete a grant that was renewed between
selecting it and destroying it.

During a rolling deploy, old code can write grants without
refresh_token_expires_at. Such a grant is now idle from its last write
(updated_at), both at refresh and in the sweep. Connected Apps keeps
listing it, so it can't become a grant that is live but hidden.
The refresh_token_expires_at column existed only to record a deadline that
updated_at already implies. Only issuance and rotation write a grant, and
every rotation sets updated_at, including rotations by code that predates
idle expiry. The column, on the other hand, could fall out of step during a
rolling deploy. Old code could write a grant without it, or rotate a grant
and leave a backfilled deadline in place, which would lapse the fresh token
early. Removing the column removes the backfill, the NULL fallback and the
stale-deadline case together.

The profile page's "apps you've authorized" link now hides once every grant
has lapsed, matching Connected Apps.
During a rolling deploy, code from before idle expiry can rotate a grant
without checking for lapse. A grant the sweep selected could therefore be
renewed before the sweep reached it. The sweep now locks each grant,
rechecks it, and spares one that is no longer lapsed.
Requests run in the browser's time zone, where 90.days is calendar arithmetic.
Across a daylight-saving change the token endpoint's cutoff moved by an hour,
and it then disagreed with the UTC sweep. The limit is now 90 days of seconds
everywhere.
The stubbed selection was a lazy relation, read after the renewal, so
the pin held without with_lock's reload. Now the sweep gets the
pre-renewal record, and the test fails without the lock.

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.

🟢 Approved

Static review found no unresolved blocking issues in expiry enforcement, cleanup, or regression coverage; tests were not executed.

No open issues 🎉

Review effort: Balanced

jeremy added a commit that referenced this pull request Oct 17, 2026
* commit '9bf097414cdb6fe87e5026778de922ac02783633':
  Hand the sweep the grant as it was read, so the race pin needs the lock
  Measure the idle limit in fixed seconds
  Recheck each lapsed grant under its lock before the sweep deletes it
  Measure idleness from the grant's last rotation, not a stored deadline
  Enforce the idle limit in the rotation itself, and lapse grants written without an expiry
  Lapse OAuth grants left idle for 90 days
…pping partway

* origin/oauth-refresh-replay:
  Make the OAuth migrations safe to run again after stopping partway
* origin/oauth-refresh-replay:
  Answer 404 from a dark endpoint however many times it's asked
* tp/oauth-refresh-replay: (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-refresh-replay:
  Bring main's tests up to this branch: Ruby 4 and the access token finder
* tp/oauth-refresh-replay:
  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-refresh-replay:
  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-refresh-replay:
  Redact retired refresh tokens from logged SQL
* tp/oauth-refresh-replay:
  Filter the code parameter by its whole name, so code_challenge stays readable
* tp/oauth-refresh-replay:
  Read a replayed refresh token's grant past the query cache
Base automatically changed from oauth-refresh-replay to main October 27, 2026 06:27
@jeremy
jeremy merged commit 73bb19e into main Oct 27, 2026
14 checks passed
@jeremy
jeremy deleted the oauth-grant-idle-expiry 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.
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