Skip to content

Track refresh token families to revoke a whole refresh chain at once - #1947

Draft
55728 wants to merge 2 commits into
mainfrom
feat/refresh-token-family
Draft

55728 wants to merge 2 commits into
mainfrom
feat/refresh-token-family

Conversation

@55728

@55728 55728 commented Sep 19, 2026

Copy link
Copy Markdown
Member

Summary

Closes #1945.

Every refresh creates a new oauth_access_tokens record, and nothing links the records of one refresh chain together. Revoking a refresh token at the revocation endpoint therefore revokes the record of the presented token only, while RFC 7009 §2.1 says:

If the particular token is a refresh token and the authorization server supports the revocation of access tokens, then the authorization server SHOULD also invalidate all access tokens based on the same authorization grant.

During the #1787 grace period (previous_refresh_token column), the previous record lives until the new access token is first used. With #1944 (revoke_previous_access_token_on_refresh false), every previous access token that has not expired yet remains usable, which is the limitation #1944 documents.

What changes

A nullable refresh_token_family_id column on oauth_access_tokens, following the additive-column pattern of previous_refresh_token, resource, refresh_token_scopes (#1932) and refresh_token_revoked_at (#1944): part of the install migration, added to existing installs by rails generate doorkeeper:refresh_token_family_id, detected with column_names.include?. The generated migration also adds a (non-unique) index, since revocation looks records up by this column.

  • Assignment: AccessToken#generate_refresh_token starts a family (SecureRandom.uuid) when a refresh token is issued outside the refresh grant. Doing it there, like refresh_token_scopes, covers the authorization code and password grants as well as host apps that create the record themselves. It is kept across validations of the same new record, and an explicitly assigned value wins.
  • Carry-over: RefreshTokenRequest copies the family of the presented refresh token onto the record it creates.
  • No backfill: a refresh token that predates the column joins a family the next time it is refreshed (AccessToken#ensure_refresh_token_family_id!). The identifier is written with a conditional UPDATE ... WHERE refresh_token_family_id IS NULL and read back, so concurrent refreshes of the same token during the grace period all continue one family.
  • Revocation: RevocableRefreshToken#revoke goes through the new AccessToken#revoke_refresh_token_family, which revokes the record and every record of its family that #revoked? does not already report as revoked (so a revocation time in the future is brought forward, as #revoke does for a single record), whichever refresh token of the chain is presented. application_id is part of the condition, so a family identifier can never reach another client's tokens, whatever a host app stores in the column.

Important

Compatibility

Relation to other work

The migration and initializer templates are ERB that renders Ruby
source, not HTML, so GuardRails reads every `<%= %>` in them as an
unescaped output and reports each new migration template as "Potential
XSS (unquoted template variable)" on the line every one of them shares,
`ActiveRecord::Migration<%= migration_version %>`. It did so on #1865,
#1932, #1942 and #1944. `migration_version` is built from
ActiveRecord::VERSION and nothing in these files ever reaches a browser.

The ignore file takes the templates directory out of the scan, the same
way .codeclimate.yml already scopes that scanner. The gemspec builds the
gem from app/, config/, lib/ and vendor/, so the file ships nowhere.
Every refresh creates a new oauth_access_tokens record, and nothing
linked the records of one refresh chain together. Revoking a refresh
token at the revocation endpoint therefore revoked the record of the
presented token only, while RFC 7009 §2.1 says the authorization server
SHOULD also invalidate the access tokens based on the same authorization
grant. Earlier records of the chain can still be usable at that point:
with the previous_refresh_token column the previous record lives until
the new access token is first used.

The records of one chain now share an identifier in a new nullable
refresh_token_family_id column on oauth_access_tokens (part of the
install migration, added to existing installs by the new
doorkeeper:refresh_token_family_id generator, with an index).

- AccessToken#generate_refresh_token starts a family when a refresh
  token is issued outside the refresh grant (authorization code,
  password, or a host app creating the record), and keeps it across
  validations of the same new record.
- RefreshTokenRequest carries the family of the presented refresh token
  onto the record it creates. A refresh token that predates the column
  is given a family at that point (#ensure_refresh_token_family_id!,
  written only while the column is still empty so that concurrent
  refreshes continue the same family), so no backfill is needed.
- RevocableRefreshToken#revoke goes through the new
  AccessToken#revoke_refresh_token_family, which revokes every unrevoked
  record of the family in one write, whichever refresh token of the chain
  is presented. A presented refresh token whose own record is already
  revoked stays revocable, since the records issued after it along the
  chain can still be live. The application is part of the condition, so a
  family identifier can never reach the tokens of another client.
  Revoking an access token, #revoke and .revoke_all_for are unchanged.

Without the column, for records without a family, and for access token
models that do not implement the new methods (the Sequel and MongoDB
extensions ship their own mixins), only the presented record is revoked,
as before. The reader and writer of the attribute ignore a missing
column.

Closes #1945
@55728 55728 self-assigned this Sep 19, 2026
@55728
55728 requested a balanced review from Copilot September 19, 2026 10:36

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 review overview

🟡 Changes recommended

Family revocation can be skipped for previously revoked members and can race with concurrent refresh issuance.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity

Open (3)
What changed in this PR

Adds refresh-token family tracking so revoking a refresh token can revoke its entire authorization chain.

Changes:

  • Adds the indexed refresh_token_family_id column and generator.
  • Propagates family IDs across refreshes and performs family-wide revocation.
  • Adds documentation and comprehensive model, request, controller, and generator specs.
File Description
.guardrails/​ignore Excludes Ruby generator templates from XSS checks.
CHANGELOG.md Documents refresh-family revocation.
README.md Explains setup and behavior.
lib/​doorkeeper/​models/​access_token_mixin.rb Implements family assignment and revocation.
lib/​doorkeeper/​oauth/​refresh_token_request.rb Carries family IDs across refreshes.
lib/​doorkeeper/​revocable_tokens/​revocable_refresh_token.rb Delegates refresh-token revocation to the family API.
lib/​generators/​doorkeeper/​refresh_token_family_id_generator.rb Adds the migration generator.
lib/​generators/​doorkeeper/​templates/​add_refresh_token_family_id_to_access_tokens.rb.erb Defines the upgrade migration.
lib/​generators/​doorkeeper/​templates/​migration.rb.erb Adds the column for new installations.
spec/​controllers/​tokens_controller_spec.rb Tests endpoint family revocation and fallback.
spec/​dummy/​db/​migrate/​20260920000000_add_refresh_token_family_id_to_access_tokens.rb Updates the dummy database.
spec/​dummy/​db/​schema.rb Records the new column and index.
spec/​generators/​refresh_token_family_id_generator_spec.rb Tests migration generation.
spec/​lib/​oauth/​refresh_token_request_spec.rb Tests family propagation and compatibility.
spec/​models/​doorkeeper/​access_token_spec.rb Tests assignment, migration, and revocation behavior.
spec/​requests/​flows/​refresh_token_spec.rb Tests family creation through authorization flows.
spec/​requests/​flows/​revoke_token_spec.rb Tests end-to-end chain revocation.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/doorkeeper/oauth/refresh_token_request.rb
Comment thread lib/doorkeeper/revocable_tokens/revocable_refresh_token.rb Outdated
Comment thread lib/doorkeeper/models/access_token_mixin.rb
@55728
55728 force-pushed the feat/refresh-token-family branch from 2f037e0 to 1c6a490 Compare September 20, 2026 09:27
@55728
55728 requested a balanced review from Copilot September 20, 2026 09:31

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 review overview

🟢 Approval recommended

The implementation is compatibility-safe, application-scoped, and thoroughly covered across model, endpoint, flow, and generator behavior.

Review effort: Balanced
Findings: None

Resolved since last review (3)

@55728
55728 requested a review from nbulaj September 20, 2026 13:41
@55728

55728 commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

Hey @nbulaj 👋

Converting this to draft to lighten your review queue — I know there are a few PRs from me stacked up right now and I don't want to overwhelm you. The code is ready whenever you have bandwidth; I'll mark it ready for review again when the timing feels right.

No rush at all! 😊

@55728
55728 marked this pull request as draft September 21, 2026 10:00

This branch has not been deployed

No deployments
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.

Add refresh token chain tracking to support grant-level revocation (RFC 7009 §2.1)

2 participants