Skip to content

Expire OAuth access tokens and rotate refresh tokens - #3081

Open
jeremy wants to merge 17 commits into
oauth-dcr-httpsfrom
oauth-refresh-tokens
Open

jeremy wants to merge 17 commits into
oauth-dcr-httpsfrom
oauth-refresh-tokens

Conversation

@jeremy

@jeremy jeremy commented Aug 28, 2026 •

Copy link
Copy Markdown
Member

Stacked on #3080. Second of three gap-closers for #2296.

The minimal stack issued non-expiring access tokens with no refresh path. 7041285 adds OAuth 2.1 token lifecycle:

  • OAuth-issued access tokens expire an hour after issuance and carry a refresh token, both generated at creation. Personal access tokens are untouched: no expiry, no refresh token.
  • refresh_token grant with rotation semantics: each refresh rotates both the access token and the refresh token on the grant record, so the previous refresh token dies with the rotation and Connected Apps still shows one grant per client. Refresh requests must present the client_id the grant was issued to.
  • Bearer authentication ignores expired tokens (Identity.find_by_permissable_access_token goes through an active scope).
  • Revocation accepts either token and kills the whole grant, per RFC 7009's "token" being either type.
  • Discovery and DCR responses advertise the refresh_token grant.

Token responses now include expires_in and refresh_token; the refresh response omits scope (unchanged from the original grant, per RFC 6749 §6).

Deliberately not done: refresh-token reuse detection beyond rotation (a reused rotated token simply fails as unknown), and refresh-token expiry — grants live until revoked from Connected Apps, matching the existing revocation model.

Update: both are now done, stacked above #3082. #3160 adds replay detection: a rotated token presented again revokes the grant, except for a 60s retry grace. #3161 adds idle expiry: a grant lapses after 90 days without a refresh.

Copilot AI balanced review requested due to automatic review settings August 28, 2026 06:16
@jeremy jeremy mentioned this pull request Aug 28, 2026
4 tasks

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.

Pull request overview

Adds OAuth 2.1 access-token expiry, refresh-token rotation, expired-token rejection, and refresh-token revocation while preserving personal-token behavior.

Changes:

  • Adds expiring OAuth tokens and rotating refresh tokens.
  • Supports refresh grants and revocation by either token.
  • Advertises and tests the expanded OAuth lifecycle.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
app/models/identity/access_token.rb Implements expiry and token rotation.
app/models/identity.rb Rejects expired bearer tokens.
app/controllers/oauth/tokens_controller.rb Handles refresh grants and token responses.
app/controllers/oauth/revocations_controller.rb Supports refresh-token revocation.
app/controllers/oauth/metadata_controller.rb Advertises refresh grants.
app/controllers/oauth/clients_controller.rb Adds refresh grants to DCR responses.
db/migrate/20260827100000_add_oauth_token_expiry.rb Adds lifecycle columns and index.
db/schema.rb Updates the MySQL schema.
db/schema_sqlite.rb Updates the SQLite schema.
test/models/identity/access_token_test.rb Tests expiry and rotation behavior.
test/integration/oauth_flow_test.rb Tests refresh and revocation flows.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread app/controllers/oauth/tokens_controller.rb Outdated
Comment thread app/models/identity/access_token.rb Outdated
Comment thread app/controllers/oauth/metadata_controller.rb
Comment thread app/controllers/oauth/clients_controller.rb
@jeremy
jeremy force-pushed the oauth-refresh-tokens branch from 7041285 to 8af37c3 Compare August 28, 2026 06:23

@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: 7041285c7b

ℹ️ 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/20260827100000_add_oauth_token_expiry.rb
Comment thread app/controllers/oauth/tokens_controller.rb Outdated
@jeremy
jeremy force-pushed the oauth-refresh-tokens branch 2 times, most recently from 4d696f6 to 530a7bb Compare August 28, 2026 06:40
@jeremy
jeremy force-pushed the oauth-refresh-tokens branch from 530a7bb to 598de77 Compare August 28, 2026 06:48

@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: 598de77115

ℹ️ 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
@jeremy
jeremy force-pushed the oauth-refresh-tokens branch from 598de77 to 0cd462c Compare August 31, 2026 19:56
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 31, 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:09.900626Z 83dd6b1 New commits
🔒 Security Review ✅ Completed 2026-10-26T04:39:47.371112Z 83dd6b1 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.

@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: 0cd462c536

ℹ️ 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
@jeremy
jeremy force-pushed the oauth-refresh-tokens branch from 8f5ef69 to 5737957 Compare October 6, 2026 02:30
@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Rebased onto #2296's token-endpoint conformance fix (comment). New commit 5737957a9:

  • A refresh request with no refresh_token or no client_id now returns invalid_request instead of invalid_grant (RFC 6749 §5.2).
  • The two "issued to this client" checks are now one, shared by both grants.

Refresh rotates the row in place, so it keeps the code's jti. Replaying the code after a refresh still revokes the rotated tokens, and a test now covers that.

Behavior change: the existing "blank refresh token" test now expects invalid_request.

@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Rebased onto #2296's authorization-endpoint fix (CSP form-action, RFC 9207 iss, canonical scope). One new commit, 52e2e56: refresh reports its scope through Oauth.canonical_scope, the same helper as the code grant, with a test that a code exchange and the refresh after it both say "read write".

@jeremy
jeremy force-pushed the oauth-refresh-tokens branch from 52e2e56 to 37b5604 Compare October 6, 2026 03:10
@jeremy
jeremy force-pushed the oauth-refresh-tokens branch from 37b5604 to a72890b Compare October 6, 2026 03:29
@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Rebased onto #2296's metadata/DCR/revocation hygiene fix (96fc380). The conflicts were mechanical and no content in this PR changed.

@jeremy
jeremy force-pushed the oauth-refresh-tokens branch from a72890b to d05407c Compare October 6, 2026 03:37
@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Rebased onto #2296's availability switch (2b6ea64). Resolved a conflict in Identity.find_by_permissable_access_token, which keeps both .active and honored?. New commit d05407c pins refresh behind the switch (404 dark, 503 paused) and updates the docs: a pause lets issued tokens expire within the hour.

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

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

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.

Comment thread app/models/identity/access_token.rb
Comment thread test/models/identity/access_token_test.rb Outdated
jeremy and others added 11 commits October 17, 2026 01:08
OAuth-issued access tokens now expire an hour after issuance and carry
a refresh token, generated at creation. Personal access tokens are
untouched: no expiry, no refresh token.

The token endpoint gains the refresh_token grant with OAuth 2.1
rotation semantics: each refresh rotates both the access and refresh
token on the grant record, so the previous refresh token dies with the
rotation and the Connected Apps list still shows one grant per client.
Refresh requests must present the client_id the grant was issued to.

Bearer authentication ignores expired tokens, and revocation accepts
either token, killing the whole grant. Discovery and DCR responses
advertise the refresh_token grant.
Refresh-token consumption raced: two concurrent refreshes could both
load the grant and both answer 200, one with already-dead credentials.
Rotation now updates guarded on the presented refresh token, so the
loser matches no row and gets invalid_grant.

The rename to refresh follows STYLE.md's bang rule — no non-bang
counterpart, no bang. Also assert grant_types in the discovery and DCR
responses so the advertised refresh grant can't silently regress.
A refresh request that carried a scope was silently ignored: the response
omitted the scope field while the rotated token kept the grant's original
permission, so a client narrowing a write token to read believed it held a
read-only token but received a write-capable one, and a broadening request
was accepted rather than rejected. Validate the requested scope against the
grant (RFC 6749 §6): reject anything beyond it with invalid_scope, narrow the
rotated token to a requested subset, and always echo the effective scope.
A refresh request missing refresh_token or client_id answered
invalid_grant, which tells a client its grant is dead, so a client-side
serialization bug ends with the client discarding a valid refresh token.
Missing parameters are now invalid_request (RFC 6749 §5.2), checked before
any lookup, the same as on the code grant.

The two "issued to this client" checks become one, run after the grant is
loaded for either grant type, so client_id is required and compared the
same way on both (RFC 6749 §4.1.3, §6).

Refresh rotates the grant row in place, so it keeps the authorization
code's jti: replaying the code after a refresh still revokes the rotated
tokens. A test now covers that.
Both grants now describe a write grant as "read write" from one helper,
Oauth.canonical_scope, so the code exchange and the refreshes after it can't
drift apart.
… expiring tokens

The refresh grant shares the token endpoint, so it already inherits the
gate: 404 while dark, 503 while issuance is paused. Pin that, and the
pilot exemption for refresh, with tests. Now that OAuth access tokens
expire, a pause lets issued tokens run out within the hour; the deployment
doc and Oauth::Availability say so.
…rant

Only an absent scope keeps the grant. A JSON null used to read as absent,
so a refresh asking for no scope at all silently came back with full write.
An array refresh_token reached the lookup as an IN list and refreshed;
an object raised and answered 500. The string check inherited from the
code grant now answers both with invalid_request.
… uses

#2296 moved the bearer lookup from Identity.find_by_permissable_access_token
to Identity::AccessToken.find_permissable, so that requests can tell which
token authenticated them. Expired tokens are still refused.
…eloading

reload replaced token on both sides of the assertion, so it held even if
the losing refresh had overwritten the winner.

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

The acknowledged decision about retaining the original refresh-grant scope still requires human resolution.

1 open issue
1 resolved since last review

Review effort: Balanced

@jeremy

jeremy commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

Review threads: 1 resolved (1 fixed).
Open for a decision: refresh narrowing narrows the grant for good — Jeremy: store grant scope separately (migration) or record a deviation.

jeremy added a commit that referenced this pull request Oct 17, 2026
* commit '017291f81210a3ef6fafa2f5a8545859a15abf4b':
  Compare a lost rotation's grant with the winner's token read before reloading
  Pin token expiry on find_permissable, which bearer authentication now uses
  Pin that refresh rejects a non-string refresh_token or client_id
  Refuse an explicit null refresh scope instead of restoring the full grant
  Keep refresh behind the issuance switch, and say what pausing does to expiring tokens
  Report refresh scopes through the same canonical scope as the code grant
  Require refresh_token and client_id on refresh as request parameters
  Reject a blank scope on refresh instead of restoring the full grant
  Validate and echo scope on refresh
  Rotate refresh tokens atomically and drop the bang
  Expire OAuth access tokens and rotate refresh tokens
… partway

* origin/oauth-dcr-https:
  Make the OAuth migrations safe to run again after stopping partway
* origin/oauth-dcr-https:
  Answer 404 from a dark endpoint however many times it's asked
* tp/oauth-dcr-https: (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-dcr-https:
  Bring main's tests up to this branch: Ruby 4 and the access token finder
* tp/oauth-dcr-https:
  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-dcr-https:
  Filter the code parameter by its whole name, so code_challenge stays readable
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