Problem/Motivation

simple_oauth_user_update() runs on every User save, including uid 0. It calls ExpiredCollector::collectForAccount(), which collects tokens by auth_user_id and by any Consumer whose default user_id matches the saved account.

Saving uid 0 deletes every token of every Consumer that has no default user. Authenticated users of those Consumers lose access.

The anonymous account is a saveable content entity. Any code that loads and saves it triggers this. The reproduction path below is one example: Group re-saving a referenced entity during anonymous-owned invitation handling.

collectForAccount() has two wider problems. It collects tokens of other users whenever the saved account is a Consumer's default user. Revocation also runs on every user save, so unrelated field writes delete tokens.

#2946882: Auth revoke on profile update added selective revocation for real users. It left this hook unconditional with no anonymous check.

Steps to reproduce

  1. Create a consumer with no default user.
  2. Issue a token under it for a real user.
  3. Save User::load(0).
  4. The real user's token is gone.

Proposed resolution

  1. Ignore anonymous-account updates in TokenExpiryTriggerHandler::handleUserUpdate() and ExpiredCollector::collectForAccount(). simple_oauth_user_update() stays unchanged.
  2. Collect only tokens that authenticate as the account: its explicit subject, or a subject-less or deleted-subject token whose Consumer defaults to the account. Skip tokens whose explicit subject is a different, existing user.
  3. Gate revocation on a credential change.
  4. Include refresh tokens in that revocation.
  5. Resolve deleted-subject tokens with one LEFT JOIN against users_field_data.

Remaining tasks

Review.

User interface changes

None.

API changes

  1. ExpiredCollector::collectForAccount() returns only tokens that authenticate as the account. It now includes refresh tokens. It returns an empty array for the anonymous account.
  2. TokenExpiryTriggerHandler::handleUserUpdate() revokes tokens only when pass, mail, status, roles, or name changes. Other user saves revoke nothing. This reverses the outcome of #3531263: collectForAccount() inconsistently removes refresh tokens when user is consumer.
  3. handleConsumerUpdate() passes $include_refresh = TRUE to collectForClient() on a credential change. The consumer credential fields are secret, client_id, user_id, confidential, grant_types, scopes, authorization_code_scopes, redirect, pkce, and status. Unpublishing a consumer now revokes its access and refresh tokens. Other consumer saves revoke access tokens only. #3436728: Check the status of a consumer adds the equivalent check at authentication time.
  4. ExpiredCollector::collect() uses accessCheck(FALSE). Cron now deletes expired tokens owned by any account.
  5. ExpiredCollector::__construct() takes a third argument, Drupal\Core\Database\Connection. The simple_oauth.expired_collector service passes @database. Omitting the argument is deprecated in simple_oauth:6.2.0 and required in simple_oauth:7.0.0.
  6. USER_CREDENTIAL_FIELDS, CONSUMER_CREDENTIAL_FIELDS, isCredentialChange(), and fieldValues() are protected for subclasses.
Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

benstallings created an issue. See original summary.

benstallings’s picture

Assigned: benstallings » Unassigned
Status: Active » Needs review
scott falconer’s picture

scott falconer’s picture

Status: Needs review » Needs work

I took a look at this one. The uid 0 guard fixes the symptom but might miss the deeper root cause where collectForAccount() can revoke other users non-refresh tokens for any Consumer default user, not only for uid 0.

scott falconer’s picture

Assigned: Unassigned » scott falconer

Follow-up to the above. @benstallings I've updated your existing MR with a few changes based on my testing:

- removed the early return in 'simple_oauth_user_update()' while keeping the
uid 0 guard in the default handler;
- fixed 'collectForAccount()`' so saving one account no longer revokes another
user's token just because the Consumer defaults to the saved account;
- added coverage for uid 0, positive default users, refresh tokens, and the
real OAuth flows.

There's a PHPstan error CI reports but that's an unchanged deprecated call that this code doesn't touch.

AI disclosure. AI was used to triage, understand, and resolve this issue. Review and testing was done manually by me

scott falconer’s picture

Assigned: scott falconer » Unassigned
Status: Needs work » Needs review
vidit.anjaria’s picture

Issue tags: +2026Sprint17
pfrilling’s picture

Assigned: Unassigned » pfrilling

Assigning myself for testing/review this week.

mglaman’s picture

Status: Needs review » Needs work

CI is red

pfrilling’s picture

Assigned: pfrilling » Unassigned
Status: Needs work » Needs review

Review flagged: the deleted-subject path hydrated every candidate and parsed rows positionally (fragile under hook_query_alter); multi-value field reordering counted as credential changes; inconsistent accessCheck(); gating lived outside the swappable-handler contract; the anonymous docblock was wrong. Smaller nits: '007'=='7' coercion, per-token (not per-user) uid IN list, missing name/client_id, transient properties compared, EntityInterface hint without hasField().

Fix (substantive work):

  1. 25d4c7b — Deleted subjects resolved via one aggregate query (groupBy('id') + MAX(auth_user_id.target_id)), deduped uid IN list, rows keyed on 'id' and read by the by-reference alias behind isset(), skipping reshaped rows rather than guessing. Unit tests use aggregate-first rows to catch positional-regression.
  2. fadd9a7 — Gating in the handler: isCredentialChange() on a canonical schema-columns-only signature (strict !==); USER_CREDENTIAL_FIELDS/CONSUMER_CREDENTIAL_FIELDS gained name/client_id; all collection queries accessCheck(FALSE); uid 0 docblock corrected; hint → FieldableEntityInterface.
  3. da915e0 — Coverage: reordering is not a change, '007''7' renames are, client_id rotation revokes refresh, testUserUpdateRevokesRefreshToken (headline behavior), and anonymous re-saves preserve unrelated tokens.

Separate test maintenance (unrelated to behavior; pre-existing issues surfaced by CI):

  1. f0c4494 — Stabilized the ScopeGranularityFormTest flake (file created 2024-12-30 in #3426090): wait for the AJAX-rebuilt subform (waitForField('Permission')) before saving.
  2. 3c6b980Oauth2ScopePluginManagerLegacyTest: four stale deprecation expectations updated to #3526770's wording.
  3. b776361EntityCollectorTest: cspell:ignore for the SQLite-stripped aggregate alias (idtarget).
  4. 65904eaAuthCodeFunctionalTest: user_pass_reset_url()OneTimeAuthentication::generateOneTimeLoginUrl() (deprecated in 11.4, removed in 13.0).

On #3531263: collectForAccount() now includes refresh tokens — reversing that issue's literal expectation, but the intent holds via the gate: routine user saves never reach the collector, so unrelated edits never remove refresh tokens; only a real credential change does.

Assumptions: routine user saves revoke nothing; routine consumer saves keep the historical access-only revocation (deliberate BC); renames are credential changes (conservative, pre-6.2.0-consistent); the ($entity->original ?? NULL) shim is kept for ^10.3 || ^11.

Status: 104/1606 green (+18/131 in simple_oauth_static_scope); phpcs, phpstan, cspell clean.

AI disclosure. AI ran six review rounds; its findings drove most of the fixes in 25d4c7b/fadd9a7, and it helped draft this comment. Review was done manually.

attilatilman’s picture

I did a review and test on my local. Everything looks good and works as expected.

mglaman’s picture

Status: Needs review » Needs work

RTBC once the test fallback and the issue summary are done

vidit.anjaria’s picture

Issue tags: -2026Sprint17 +2026Sprint18
pfrilling’s picture

Assigned: Unassigned » pfrilling

Assigning myself to work on this in sprint 18.

pfrilling’s picture

Issue summary: View changes
Status: Needs work » Needs review
Related issues: +#3436728: Check the status of a consumer

I updated the issue summary with an API changes section. It covers the change to user token revocation and refresh token handling. It also covers the cron access check and a new constructor argument on the expired token collector.

The one-time login fallback is no longer needed here. #3620861: Fix user_pass_reset_url() deprecation compatibility in AuthCodeFunctionalTest and #3620858: Fix deprecation message strings in Oauth2ScopePluginManagerLegacyTest landed, and this branch is rebased on them.

This issue overlaps with #3436728: Check the status of a consumer. Unpublishing a consumer now revokes its tokens. That issue adds a status check at authentication time, which covers consumers that are already unpublished. Whichever lands second should check for a duplicate status check.

Drafted with the help of an LLM.

benstallings’s picture

Status: Needs review » Reviewed & tested by the community

This looks good to me and passes review. Note that there is currently no test coverage for the $database = NULL BC/deprecation fallback path.

alex ua’s picture

Status: Reviewed & tested by the community » Needs work

Runtime retest against tokens issued through the real authorization_code flow: the user-side contract holds, but the consumer-side refresh revocation cannot reach real refresh tokens because RefreshTokenEntityNormalizer stores them with client = NULL, so unpublishing a consumer or rotating its secret leaves the refresh grant working. Details, evidence, and a fix direction are on the MR: https://git.drupalcode.org/project/simple_oauth/-/merge_requests/229#not...

AI was used to assist with this review.

pfrilling’s picture

Assigned: pfrilling » Unassigned
Status: Needs work » Needs review

I added the latest changes that Alex uncovered and added the missing test that @benstallings mentioned. Additional details in the MR.

scott falconer’s picture

Thanks @pfrilling, I tested and the token revocation and upgrade cleanup look good. The only remaining thing I see would be moving the new cleanup to a post-update hook so the entity loading&deletion runs after all schema changes are complete.

pfrilling’s picture

Good call @scott falconer. I moved the update from .install to .post_update.php.

scott falconer’s picture

Status: Needs review » Reviewed & tested by the community

Thanks @pfrilling, all looks good to me! Marking this as rtbc.

e0ipso’s picture

Status: Reviewed & tested by the community » Needs work
vidit.anjaria’s picture

Issue tags: -2026Sprint18 +2026Sprint19