fix(social): proactive token refresh actually refreshes (not just verifies) - #30
Merged
Merged
Conversation
…ifies)
Three orthogonal fixes that together close the gap where social tokens
were silently aging out without ever being refreshed, then dying at the
provider when the refresh_token also got revoked.
The original failure mode: a user's X token expired because the hourly
proactive-refresh cron's smart `verify()` skip-logic kept saying 'token
still works, no need to refresh', and once the token actually expired,
the cron's WHERE clause excluded it from future runs. By the time anyone
noticed, the refresh_token at X was also gone.
(C) ConnectionVerifier: rename private `refreshTokenIfNeeded` →
public `refreshToken`. Callers that want the smart 'try
access_token first' behavior keep using `verify()`. Callers that
want a proactive refresh (the cron) call `refreshToken` directly.
(B) RefreshExpiringTokens command: drop the
`where('token_expires_at', '>', now())` filter. Already-expired
tokens now get a last-chance refresh attempt before the
refresh_token also dies at the provider. Status filter
(`Connected`) still excludes accounts already marked TokenExpired.
(D) RefreshSocialToken job: switch from `verify()` to
`refreshToken()`, and on `TokenExpiredException` call
`markAsTokenExpired` so the user is notified immediately. The lock
+ transition detection in markAsTokenExpired prevents notification
spam if subsequent cron passes also fail.
Tests:
- 3 new tests for RefreshSocialToken (calls refreshToken not verify,
marks TokenExpired on TokenExpiredException, logs warning on other
errors)
- Updated RefreshExpiringTokens test to assert already-expired tokens
are now dispatched (was previously asserted as 'should NOT')
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
After #29 shipped, an audit of the daily commands revealed that the hourly proactive-refresh cron was silently letting tokens age out and die at the provider. The original failure (PR #29) was the visible symptom; this PR fixes the underlying cause so the same situation doesn't recur.
Why tokens were dying despite the hourly cron:
RefreshSocialTokenjob calledConnectionVerifier::verify()— which is optimized for the publish-time path and tries the access_token first. If verify succeeds, no refresh happens. So the cron's whole purpose (refresh proactively) was defeated by verify()'s smart-skip.RefreshExpiringTokenscommand excluded already-expired tokens viawhere('token_expires_at', '>', now()). So if a token aged out between cron passes, it never got a last-chance refresh — by the time anyone noticed, the refresh_token at the provider had also been revoked.RefreshSocialTokendid fail, it just logged a warning and left the accountConnected. User was never notified, the cron kept retrying every hour, and the account only flipped toTokenExpiredwhen the user tried to publish (PR fix(social): unify token-expired handling across all publishers #29's fix) or the dailyCheckSocialConnectionsran.The three orthogonal fixes
(C)
ConnectionVerifier::refreshTokenis now publicRenamed from private
refreshTokenIfNeeded→ publicrefreshToken. The body is unchanged (lock + per-platform refresh dispatch).verify()still uses it internally. Callers that want a proactive refresh (the cron) call it directly and skip verify()'s smart-skip logic.(B) Cron window widened to include already-expired tokens
Removed
where('token_expires_at', '>', now())inRefreshExpiringTokens. Already-expired-but-still-Connected tokens now get a last-chance refresh attempt before the refresh_token also dies at the provider. Thestatus = Connectedfilter still excludes accounts already inTokenExpiredorDisconnectedstate.(D)
RefreshSocialTokenmarks TokenExpired on refresh failureTwo separate catch branches now:
TokenExpiredException(refresh_token rejected → terminal): callmarkAsTokenExpired($e->getMessage()). The lock + transition detection inmarkAsTokenExpired(from fix(social): unify token-expired handling across all publishers #29) means user gets exactly one notification per transition, no spam if subsequent cron passes retry.Throwable(network blip, 5xx, etc. → transient): log warning, account stays Connected, next cron pass tries again.End-to-end behavior now
Happy path:
Refresh_token revoked at provider:
status = Connectedno longer matches) → no spamAlready-expired token (B fix):
> nowfilter) → either succeeds (provider hasn't reaped yet) or falls into the TokenExpired path aboveTest plan
php artisan test --compact --parallel— 1502 passed, 2 skipped, 0 failed (+3 over main, all new)refresh job calls refreshToken (not verify) on the verifierrefresh job marks account as TokenExpired when refresh_token is rejectedrefresh job logs warning on non-token errors and leaves status aloneit dispatches refresh jobs for tokens expiring within 2 hours or already expired(flipped from "should NOT" to "SHOULD" for already-expired)status=connected+token_expires_at < NOW()) — no notification spike on deploysocial:refresh-expiring-tokensagainst a real expired X account → confirmmarkAsTokenExpiredfires + email arrivesKnown gaps (out of scope, follow-up)
token_expires_at = null(Facebook pages, Mastodon, Bluesky, Instagram-via-Facebook) are still only checked by the dailyCheckSocialConnections. Provider-side revocations on these accounts have up to a 24h discovery window. Worth a separate PR if it becomes a problem.