Skip to content

Fix social account disconnects: refresh tokens per platform's refresh model#171

Merged
paulocastellano merged 11 commits into
mainfrom
fix/x-refresh-token-rotation
Jul 3, 2026
Merged

Fix social account disconnects: refresh tokens per platform's refresh model#171
paulocastellano merged 11 commits into
mainfrom
fix/x-refresh-token-rotation

Conversation

@paulocastellano

@paulocastellano paulocastellano commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #126.

Problem

Social accounts were being marked disconnected far more often than they should be, forcing frequent reconnects. The root cause was proactive token refresh that ignored how each platform actually refreshes its tokens — and it broke in two opposite ways.

Rotating refresh-token platforms (X, LinkedIn, TikTok, Pinterest, YouTube, Bluesky). Their refresh tokens are single-use / rotating — every refresh returns a new pair and invalidates the old one; sending an already-rotated token can invalidate the whole token family and force a reconnect. The old code rotated far too often:

  • RefreshExpiringTokens refreshed anything expiring within 2h and ran hourly, but X access tokens live exactly 2h — so every X account was rotated every hour while still valid.
  • RefreshSocialToken called refreshToken() directly, bypassing verify()'s access-token-first guard.
  • A first 4xx disconnected the account without checking whether a concurrent refresh had already persisted a working token.
  • Every publisher/analytics service (23 sites) also refreshed on "expiring soon", rotating still-valid tokens.

Extension-token platforms (Instagram, Threads). These have no separate refresh token — they refresh by extending the access token itself, and per Meta's docs the token must be unexpired to refresh ("at least 24 hours old but has not expired"; once expired it "can no longer be refreshed"). So they must be refreshed while still valid — the opposite of the rotating platforms.

Fix

Gate proactive refresh on the platform's refresh model:

  • Platform::extendsAccessTokenOnRefresh() — true only for Instagram/Threads.
  • SocialAccount::needsProactiveTokenRefresh() — refresh when expired for rotating platforms, OR when expiring-soon for extension platforms.
  • Proactive job (RefreshSocialToken): route rotating platforms through verify() (access-token-first) so they only rotate once actually expired; extend (refreshToken()) extension platforms while still valid.
  • Per-model refresh window (RefreshExpiringTokens, cron every 15 min): rotating platforms use a tight 30-minute window (verify-first means being in-window just re-verifies, never rotates a still-valid token); extension platforms (Instagram/Threads) get a 24-hour lead, because they can't be refreshed once expired and the job runs on the backlog-prone default queue — a short window risked a permanent lapse under queue delay.
  • Never persist a null token expiry for Instagram/Threads. A null token_expires_at silently drops an account from every refresh path (the cron's whereNotNull and the is_token_expired/is_token_expiring_soon checks), so the token lapses. Threads no longer completes a connect with only the ~1h short-lived token when the long-lived exchange fails (it fails the connect so the user retries), and a refresh that omits expires_in now defaults to 60 days (Platform::LONG_LIVED_TOKEN_TTL_SECONDS, centralized) for both Instagram and Threads.
  • A Meta rate-limit no longer disconnects a valid Instagram/Threads token. Meta returns rate-limit (code 4/17) and transient (code 1/2) errors as HTTP 4xx with type: OAuthException, and TokenRefreshClient classified every non-5xx/429 failure as a dead token → a throttled proactive refresh disconnected still-valid tokens (and the wider refresh cadence raised the odds). Classification now keys on error code 190 — the signal the publish exceptions already use — via a shared Meta\GraphError helper: only a genuine 190 disconnects; every other Meta 4xx is transient (PlatformUnavailable) and retried next cycle. The same over-broad type-based check in verifyInstagram/verifyFacebook/verifyThreads was replaced with the helper so verify and refresh agree.
  • Lost-rotation race: verify() now reloads and uses a concurrently-refreshed token before marking an account expired.
  • All 23 publisher/analytics pre-checks use needsProactiveTokenRefresh().

Platform audit

All 13 platforms were checked against the code and the providers' docs:

  • Extension model — must refresh while valid: Instagram, Threads (the only two; both handled).
  • Rotating / separate refresh_token — safe (refresh works after the access token expires): X, LinkedIn, LinkedIn Page, TikTok, YouTube, Pinterest, Bluesky.
  • Never expire — excluded from the proactive window (whereNotNull): Facebook, Instagram-via-Facebook, Mastodon, Telegram, Discord.

Cold review of existing-token / deploy safety

An adversarial pass over the existing social_accounts data confirmed no currently-connected row is broken, disconnected, or unnecessarily rotated versus main: every divergence either reduces rotation/false-disconnects for rotating platforms, widens the harmless extend window for Instagram/Threads, or is a no-op for the null-expiry / never-expire rows. The only real risk it surfaced — the Meta rate-limit false-disconnect above — is fixed in this PR.

Tests

  • Proactive job routes rotating platforms through verify() (still-valid X token is not rotated) and extends still-valid Instagram and Threads tokens.
  • Per-model window: extension-model accounts are dispatched well ahead of expiry while rotating accounts with the same expiry are not.
  • Threads connect fails (persists nothing) when the long-lived exchange fails, and records a 60-day expiry when expires_in is omitted; Instagram/Threads refresh do the same.
  • Meta rate-limit handling: a 400 OAuthException code 4/17 on refresh raises PlatformUnavailable (not a disconnect), code 190 raises TokenExpired; the proactive job leaves a rate-limited Instagram account Connected; verifyInstagram returns false (no disconnect) on a rate-limit; Meta\GraphError unit-tested for 190 vs 4/17/2/none.
  • ConnectionVerifier regression test: a 4xx refresh with a concurrently-rotated token does not disconnect the account.
  • needsProactiveTokenRefresh() model test covers the rotating-vs-extension branching.
  • No-rotation tests for the X and LinkedIn publishers.
  • Full suite green: 2370 passing, 2 skipped, 0 failures.

X OAuth2 refresh tokens are single-use: each refresh rotates the pair and
invalidates the previous refresh_token, and reusing a rotated one kills the
whole family. Three things made this fragile and disconnected accounts far
more often than necessary:

- The proactive refresh job called refreshToken() directly, bypassing the
  access-token-first guard in verify() and rotating on every run.
- RefreshExpiringTokens used a 2h window on an hourly schedule — equal to the
  2h access-token lifetime — so every X account was rotated every hour even
  while its token was still valid.
- A single 4xx refresh failure disconnected the account without checking
  whether a concurrent refresh had already persisted a working token.

Changes:
- RefreshSocialToken now routes through verify() (access-token-first), so it
  only rotates when the access_token is actually invalid.
- Shrink the proactive window to 30m and run the command every 15m, so the
  window still covers the run interval but rotation happens near real expiry.
- verify() tolerates the lost-rotation race: on a 4xx refresh, reload and
  verify with a concurrently-refreshed token before marking TokenExpired.

Refs #126
Every publisher and analytics service proactively refreshed the token when
it was expired OR merely "expiring soon" (within 15 min), calling
refreshToken() directly. For X (and other single-use-refresh providers)
that rotated a perfectly valid access_token whenever an operation ran in the
token's final 15 minutes — the same needless rotation that breaks the
refresh_token chain and disconnects accounts.

Narrow every pre-check to refresh only when the token is actually expired. A
still-valid token is used as-is; if it expires mid-operation the existing
reactive retry (PublishToSocialPlatform) refreshes and retries.

- Drop `|| is_token_expiring_soon` from all 23 publisher/analytics pre-checks.
- Remove the now-unused `isTokenExpiringSoon` accessor (no references remain
  anywhere in the repo).
- The reactive retry path (AbstractLinkedInPublisher::retryWithRefresh) and
  the expired-token path are unchanged.

Refs #126
Cold review caught a regression from the two previous commits. Instagram and
Threads use long-lived tokens refreshed by EXTENDING the access_token itself
(grant_type=ig_refresh_token / th_refresh_token) — they have no separate
refresh_token and CANNOT be refreshed once expired. The anti-over-rotation rule
("only refresh a token once it's actually expired") is right for rotating
single-use refresh_token platforms but wrong for these: it left IG/Threads
tokens to lapse, after which the extend call fails and the account disconnects
(~every 60 days).

Gate the anti-rotation on the platform's refresh model:
- Platform::extendsAccessTokenOnRefresh() — true for Instagram/Threads.
- SocialAccount::needsProactiveTokenRefresh() — expired for rotating platforms,
  OR expiring-soon for extension platforms (restores isTokenExpiringSoon).
- RefreshSocialToken extends (refreshToken) extension-model tokens while still
  valid, and verifies (access-token-first) rotating ones.
- All 23 publisher/analytics pre-checks now use needsProactiveTokenRefresh().

Tests: proactive job extends a still-valid Instagram token; a model test covers
the rotating-vs-extension branching; existing X/LinkedIn anti-rotation tests
are unchanged.

Refs #126
The extend-while-valid path is shared by Instagram and Threads via
Platform::extendsAccessTokenOnRefresh(); add the Threads sibling of the
Instagram job test so both extension-model platforms are locked down.

Refs #126
@paulocastellano paulocastellano marked this pull request as ready for review July 3, 2026 13:00
@paulocastellano paulocastellano changed the title Fix X (Twitter) accounts disconnecting too often (refresh token chain) Fix social account disconnects: refresh tokens per platform's refresh model Jul 3, 2026
Extension-model tokens (Instagram/Threads) can't be refreshed once they
expire, so the shared 30-minute cron window left only a ~15-minute buffer
against queue backlog on the default queue — and a lapse forces a full
reconnect. Rotating platforms go through verify() and won't rotate a
still-valid token, so they keep the tight 30-minute window; extension
platforms now get a 24-hour lead via a per-model query.
A null token_expires_at drops an account from every refresh path (the
cron's whereNotNull filter and the is_token_expired / is_token_expiring_soon
checks all treat null as "nothing to do"), so the token silently lapses.
Threads could persist null two ways: the long-lived exchange failing at
connect (kept the ~1h short-lived token) — now fails the connect instead;
and a refresh response omitting expires_in — now defaults to 60 days for
both Instagram and Threads, matching the X refresh convention.
…lper

The 60-day fallback used when Meta omits expires_in was duplicated as a bare
5184000 across the Instagram/Threads connect and refresh code; it now lives in
one place, Platform::LONG_LIVED_TOKEN_TTL_SECONDS. Also renames
Platform::extensionModelValues() to accessTokenExtendingPlatformValues() so the
name states what it returns without needing the extendsAccessTokenOnRefresh
docblock.
TokenRefreshClient classified every non-5xx/429 refresh failure as a dead
token. Meta returns rate-limit (code 4/17) and transient (code 1/2) errors as
HTTP 4xx with type OAuthException, so a throttled proactive refresh was
disconnecting still-valid Instagram/Threads tokens — and the wider 24h/15-min
refresh cadence raised the odds of hitting it.

Classification now keys on error code 190 (the signal the publish exceptions
already use): only a genuine 190 disconnects; every other Meta 4xx is treated as
transient (PlatformUnavailable) and retried next cycle. The same over-broad
type-based check in verifyInstagram/verifyFacebook/verifyThreads is replaced
with the shared Meta\GraphError helper so verify and refresh agree.
The connect flow logged the raw response body of a failed token exchange,
unlike the TokenRedactor discipline used everywhere else. A failure body
carries no token, but redacting keeps it consistent and defensive.
refreshThenVerify only guarded refreshToken(); the verify that followed was
outside the try, so the sub-commit window where a lock-skipped refresh reloads a
not-yet-persisted token surfaced as a 401 there and falsely disconnected a
still-usable single-use-refresh_token account (X/LinkedIn). The refresh and the
verify that follows it now share one recovery: on a TokenExpiredException,
reload and, if a concurrent refresh has since persisted a fresh access_token,
verify with it instead of giving up.
…tTokenTtlSeconds()

The single LONG_LIVED_TOKEN_TTL_SECONDS constant (Meta 60-day) plus a loose
inline 7200 for X made it unclear which networks each value applied to. Express
the fallback TTL as a per-platform match method instead, matching how the enum
already exposes every other per-network value, so the network->value mapping is
visible in one place: X 2h, Instagram/Threads 60d, everyone else null (they
always return expires_in). Behavior is unchanged.
@paulocastellano paulocastellano merged commit 665cc22 into main Jul 3, 2026
2 checks passed
@paulocastellano paulocastellano deleted the fix/x-refresh-token-rotation branch July 3, 2026 16:35
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.

X (Twitter) accounts disconnect too often, refresh token chain breaks

1 participant