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
- Create a consumer with no default user.
- Issue a token under it for a real user.
- Save
User::load(0). - The real user's token is gone.
Proposed resolution
- Ignore anonymous-account updates in
TokenExpiryTriggerHandler::handleUserUpdate()andExpiredCollector::collectForAccount().simple_oauth_user_update()stays unchanged. - 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.
- Gate revocation on a credential change.
- Include refresh tokens in that revocation.
- Resolve deleted-subject tokens with one
LEFT JOINagainstusers_field_data.
Remaining tasks
Review.
User interface changes
None.
API changes
ExpiredCollector::collectForAccount()returns only tokens that authenticate as the account. It now includes refresh tokens. It returns an empty array for the anonymous account.TokenExpiryTriggerHandler::handleUserUpdate()revokes tokens only whenpass,mail,status,roles, ornamechanges. Other user saves revoke nothing. This reverses the outcome of #3531263: collectForAccount() inconsistently removes refresh tokens when user is consumer.handleConsumerUpdate()passes$include_refresh = TRUEtocollectForClient()on a credential change. The consumer credential fields aresecret,client_id,user_id,confidential,grant_types,scopes,authorization_code_scopes,redirect,pkce, andstatus. 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.ExpiredCollector::collect()usesaccessCheck(FALSE). Cron now deletes expired tokens owned by any account.ExpiredCollector::__construct()takes a third argument,Drupal\Core\Database\Connection. Thesimple_oauth.expired_collectorservice passes@database. Omitting the argument is deprecated in simple_oauth:6.2.0 and required in simple_oauth:7.0.0.USER_CREDENTIAL_FIELDS,CONSUMER_CREDENTIAL_FIELDS,isCredentialChange(), andfieldValues()are protected for subclasses.
Issue fork simple_oauth-3616124
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
Comment #3
benstallings commentedComment #4
scott falconer commentedComment #5
scott falconer commentedI 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.
Comment #6
scott falconer commentedFollow-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
Comment #7
scott falconer commentedComment #8
vidit.anjaria commentedComment #9
pfrillingAssigning myself for testing/review this week.
Comment #10
mglamanCI is red
Comment #11
pfrillingReview 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; inconsistentaccessCheck(); 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, missingname/client_id, transient properties compared,EntityInterfacehint withouthasField().Fix (substantive work):
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 behindisset(), skipping reshaped rows rather than guessing. Unit tests use aggregate-first rows to catch positional-regression.fadd9a7— Gating in the handler:isCredentialChange()on a canonical schema-columns-only signature (strict!==);USER_CREDENTIAL_FIELDS/CONSUMER_CREDENTIAL_FIELDSgainedname/client_id; all collection queriesaccessCheck(FALSE); uid 0 docblock corrected; hint →FieldableEntityInterface.da915e0— Coverage: reordering is not a change,'007'→'7'renames are,client_idrotation revokes refresh,testUserUpdateRevokesRefreshToken(headline behavior), and anonymous re-saves preserve unrelated tokens.Separate test maintenance (unrelated to behavior; pre-existing issues surfaced by CI):
f0c4494— Stabilized theScopeGranularityFormTestflake (file created 2024-12-30 in #3426090): wait for the AJAX-rebuilt subform (waitForField('Permission')) before saving.3c6b980—Oauth2ScopePluginManagerLegacyTest: four stale deprecation expectations updated to #3526770's wording.b776361—EntityCollectorTest:cspell:ignorefor the SQLite-stripped aggregate alias (idtarget).65904ea—AuthCodeFunctionalTest: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.Comment #12
attilatilman commentedI did a review and test on my local. Everything looks good and works as expected.
Comment #13
mglamanRTBC once the test fallback and the issue summary are done
Comment #14
vidit.anjaria commentedComment #15
pfrillingAssigning myself to work on this in sprint 18.
Comment #16
pfrillingNoting that this is blocked by #3620861: Fix user_pass_reset_url() deprecation compatibility in AuthCodeFunctionalTest and #3620858: Fix deprecation message strings in Oauth2ScopePluginManagerLegacyTest
Comment #17
pfrillingI 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.
Comment #18
benstallings commentedThis looks good to me and passes review. Note that there is currently no test coverage for the $database = NULL BC/deprecation fallback path.
Comment #19
alex ua commentedRuntime 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.
Comment #20
pfrillingI added the latest changes that Alex uncovered and added the missing test that @benstallings mentioned. Additional details in the MR.
Comment #21
scott falconer commentedThanks @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.
Comment #22
pfrillingGood call @scott falconer. I moved the update from .install to .post_update.php.
Comment #23
scott falconer commentedThanks @pfrilling, all looks good to me! Marking this as rtbc.
Comment #24
e0ipsoComment #25
vidit.anjaria commented