From 2f4b9741304e292c4902cf64f3ee34382e84f80b Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 2 Jul 2026 20:32:55 -0300 Subject: [PATCH 01/11] Fix X token chain breaking from over-rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Commands/RefreshExpiringTokens.php | 4 +- app/Jobs/RefreshSocialToken.php | 2 +- app/Services/Social/ConnectionVerifier.php | 39 +++++++++++++++---- routes/console.php | 2 +- .../Commands/RefreshExpiringTokensTest.php | 10 ++--- tests/Feature/Jobs/RefreshSocialTokenTest.php | 37 +++++++++++++++--- .../Social/ConnectionVerifierTest.php | 31 +++++++++++++++ 7 files changed, 102 insertions(+), 23 deletions(-) diff --git a/app/Console/Commands/RefreshExpiringTokens.php b/app/Console/Commands/RefreshExpiringTokens.php index 200c3063..981fe1b1 100644 --- a/app/Console/Commands/RefreshExpiringTokens.php +++ b/app/Console/Commands/RefreshExpiringTokens.php @@ -13,7 +13,7 @@ class RefreshExpiringTokens extends Command { protected $signature = 'social:refresh-expiring-tokens'; - protected $description = 'Proactively refresh tokens expiring in the next 2 hours (or already expired)'; + protected $description = 'Proactively refresh tokens expiring in the next 30 minutes (or already expired)'; public function handle(): void { @@ -22,7 +22,7 @@ public function handle(): void SocialAccount::query() ->where('status', Status::Connected) ->whereNotNull('token_expires_at') - ->where('token_expires_at', '<=', now()->addHours(2)) + ->where('token_expires_at', '<=', now()->addMinutes(30)) ->chunk(50, function ($accounts) use (&$count) { foreach ($accounts as $account) { RefreshSocialToken::dispatch($account); diff --git a/app/Jobs/RefreshSocialToken.php b/app/Jobs/RefreshSocialToken.php index 25b6b1bf..53df7da8 100644 --- a/app/Jobs/RefreshSocialToken.php +++ b/app/Jobs/RefreshSocialToken.php @@ -24,7 +24,7 @@ public function __construct(public SocialAccount $account) {} public function handle(ConnectionVerifier $verifier): void { try { - $verifier->refreshToken($this->account); + $verifier->verify($this->account); } catch (PlatformUnavailableException $e) { Log::warning('Token refresh skipped: platform unavailable', [ 'account_id' => $this->account->id, diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 085699b1..4cb29072 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -31,9 +31,7 @@ public function verify(SocialAccount $account): bool // refresh, so proactive refreshes during races cause false-positive // disconnects even though the access_token still works fine. if ($account->is_token_expired) { - $this->refreshToken($account); - - return $this->callVerifyEndpoint($account); + return $this->refreshThenVerify($account); } try { @@ -41,14 +39,39 @@ public function verify(SocialAccount $account): bool } catch (TokenExpiredException $e) { // Verify returned 401: the access_token is actually invalid. // Refresh and retry once with the new token. - try { - $this->refreshToken($account); - } catch (TokenExpiredException) { - throw $e; + return $this->refreshThenVerify($account, $e); + } + } + + /** + * Refresh the token, then verify with the new one. + * + * If the refresh is rejected (4xx) but a concurrent refresh has already + * rotated the account and persisted a fresh access_token, reload and + * verify with that token instead of giving up — X (and other providers + * that single-use their refresh_token) otherwise disconnect a still-usable + * account whenever two refreshes race and one loses the rotation. + * + * @throws TokenExpiredException + * @throws PlatformUnavailableException + */ + private function refreshThenVerify(SocialAccount $account, ?TokenExpiredException $original = null): bool + { + $accessTokenBeforeRefresh = $account->access_token; + + try { + $this->refreshToken($account); + } catch (TokenExpiredException $e) { + $account->refresh(); + + if ($account->access_token !== $accessTokenBeforeRefresh) { + return $this->callVerifyEndpoint($account); } - return $this->callVerifyEndpoint($account); + throw $original ?? $e; } + + return $this->callVerifyEndpoint($account); } /** diff --git a/routes/console.php b/routes/console.php index 0fa9db84..dd2b3ef6 100644 --- a/routes/console.php +++ b/routes/console.php @@ -14,7 +14,7 @@ Schedule::command(ProcessScheduledPosts::class)->everyMinute()->withoutOverlapping()->onOneServer(); Schedule::command(CheckSocialConnections::class)->daily()->withoutOverlapping()->onOneServer(); -Schedule::command(RefreshExpiringTokens::class)->hourly()->withoutOverlapping()->onOneServer(); +Schedule::command(RefreshExpiringTokens::class)->everyFifteenMinutes()->withoutOverlapping()->onOneServer(); Schedule::command(RecoverStuckPosts::class)->everyThirtyMinutes()->withoutOverlapping()->onOneServer(); Schedule::command(FireScheduleTriggers::class)->everyMinute()->withoutOverlapping()->onOneServer(); Schedule::command(ProcessAutomationDelays::class)->everyMinute()->withoutOverlapping()->onOneServer(); diff --git a/tests/Feature/Commands/RefreshExpiringTokensTest.php b/tests/Feature/Commands/RefreshExpiringTokensTest.php index 5656d28b..ea8b8019 100644 --- a/tests/Feature/Commands/RefreshExpiringTokensTest.php +++ b/tests/Feature/Commands/RefreshExpiringTokensTest.php @@ -9,25 +9,25 @@ use App\Models\Workspace; use Illuminate\Support\Facades\Queue; -test('it dispatches refresh jobs for tokens expiring within 2 hours or already expired', function () { +test('it dispatches refresh jobs for tokens expiring within 30 minutes or already expired', function () { Queue::fake(); $workspace = Workspace::factory()->create(); - // Should be refreshed (expires in 1 hour) + // Should be refreshed (expires in 15 minutes — inside the proactive window) $expiringSoon = SocialAccount::factory()->create([ 'workspace_id' => $workspace->id, 'platform' => Platform::LinkedIn, 'status' => Status::Connected, - 'token_expires_at' => now()->addHour(), + 'token_expires_at' => now()->addMinutes(15), ]); - // Should NOT be refreshed (expires in 5 hours — outside the proactive window) + // Should NOT be refreshed (expires in 1 hour — outside the proactive window) SocialAccount::factory()->create([ 'workspace_id' => $workspace->id, 'platform' => Platform::Instagram, 'status' => Status::Connected, - 'token_expires_at' => now()->addHours(5), + 'token_expires_at' => now()->addHour(), ]); // SHOULD be refreshed (already expired — last-chance attempt before the diff --git a/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index c9a9226d..6299504e 100644 --- a/tests/Feature/Jobs/RefreshSocialTokenTest.php +++ b/tests/Feature/Jobs/RefreshSocialTokenTest.php @@ -11,6 +11,7 @@ use App\Models\User; use App\Models\Workspace; use App\Services\Social\ConnectionVerifier; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Queue; @@ -24,22 +25,46 @@ ]); }); -test('refresh job calls refreshToken (not verify) on the verifier', function () { +test('refresh job routes through verify (access-token-first) not refreshToken', function () { $verifier = mock(ConnectionVerifier::class); - $verifier->shouldReceive('refreshToken')->once()->with( + $verifier->shouldReceive('verify')->once()->with( Mockery::on(fn ($account) => $account->id === $this->account->id) ); - $verifier->shouldNotReceive('verify'); + $verifier->shouldNotReceive('refreshToken'); app()->instance(ConnectionVerifier::class, $verifier); (new RefreshSocialToken($this->account))->handle($verifier); }); +test('proactive refresh does NOT rotate the X refresh token while the access token still works', function () { + Http::fake([ + config('trypost.platforms.x.api').'/users/me' => Http::response(['data' => ['id' => '123']], 200), + config('trypost.platforms.x.api').'/oauth2/token' => Http::response([ + 'access_token' => 'should-not-be-used', + 'refresh_token' => 'should-not-be-used', + 'expires_in' => 7200, + ], 200), + ]); + + // Token is "expiring soon" (inside the proactive window) but still valid. + $this->account->update([ + 'token_expires_at' => now()->addMinutes(20), + 'refresh_token' => 'original-refresh-token', + ]); + + (new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class)); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/users/me')); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/oauth2/token')); + expect($this->account->fresh()->refresh_token)->toBe('original-refresh-token'); + expect($this->account->fresh()->status)->toBe(Status::Connected); +}); + test('refresh job marks account as TokenExpired when refresh_token is rejected', function () { Queue::fake(); $verifier = mock(ConnectionVerifier::class); - $verifier->shouldReceive('refreshToken')->once()->andThrow( + $verifier->shouldReceive('verify')->once()->andThrow( new TokenExpiredException('refresh_token revoked') ); app()->instance(ConnectionVerifier::class, $verifier); @@ -61,7 +86,7 @@ }); $verifier = mock(ConnectionVerifier::class); - $verifier->shouldReceive('refreshToken')->once()->andThrow(new RuntimeException('network blip')); + $verifier->shouldReceive('verify')->once()->andThrow(new RuntimeException('network blip')); app()->instance(ConnectionVerifier::class, $verifier); (new RefreshSocialToken($this->account))->handle($verifier); @@ -79,7 +104,7 @@ }); $verifier = mock(ConnectionVerifier::class); - $verifier->shouldReceive('refreshToken')->once()->andThrow( + $verifier->shouldReceive('verify')->once()->andThrow( new PlatformUnavailableException('X API returned 503 during token refresh', 503) ); app()->instance(ConnectionVerifier::class, $verifier); diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 332d6860..74d1bbe9 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\SocialAccount\Status; use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; @@ -414,6 +415,36 @@ expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); }); +test('does not disconnect when a concurrent refresh already rotated the token (lost-rotation race)', function () { + Http::fake([ + // Our stale refresh_token is rejected — a concurrent process already used it. + config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400), + // But the access_token the winning refresh persisted still works. + config('trypost.platforms.x.api').'/users/me' => Http::response(['data' => ['id' => '123']], 200), + ]); + + $account = SocialAccount::factory()->x()->create([ + 'status' => Status::Connected, + 'access_token' => 'stale-token', + 'refresh_token' => 'already-rotated', + 'token_expires_at' => now()->subHour(), + ]); + + // Simulate the concurrent refresh: a separate instance persists a fresh, + // valid token (through the encrypted cast) while our in-memory copy stays + // the stale, expired one. + SocialAccount::find($account->id)->update([ + 'access_token' => 'fresh-token', + 'token_expires_at' => now()->addHours(2), + ]); + + expect((new ConnectionVerifier)->verify($account))->toBeTrue(); + expect($account->fresh()->status)->toBe(Status::Connected); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/users/me') + && $request->header('Authorization')[0] === 'Bearer fresh-token'); +}); + test('4xx during refresh keeps raising TokenExpiredException', function () { Http::fake([ config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400), From 3dd1804c8e80c6ed4e25e28afb356782a8c84f2f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 08:30:58 -0300 Subject: [PATCH 02/11] Stop publishers/analytics from rotating still-valid tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Models/SocialAccount.php | 7 ------ .../Social/AbstractLinkedInPublisher.php | 2 +- app/Services/Social/BlueskyPublisher.php | 2 +- app/Services/Social/InstagramAnalytics.php | 4 +-- app/Services/Social/InstagramPublisher.php | 2 +- app/Services/Social/LinkedInPageAnalytics.php | 4 +-- app/Services/Social/PinterestAnalytics.php | 4 +-- app/Services/Social/PinterestPublisher.php | 4 +-- app/Services/Social/ThreadsAnalytics.php | 4 +-- app/Services/Social/ThreadsPublisher.php | 2 +- app/Services/Social/TikTokAnalytics.php | 2 +- app/Services/Social/TikTokCreatorInfo.php | 2 +- app/Services/Social/TikTokPublisher.php | 2 +- app/Services/Social/XAnalytics.php | 4 +-- app/Services/Social/XPublisher.php | 4 +-- app/Services/Social/YouTubeAnalytics.php | 4 +-- app/Services/Social/YouTubePublisher.php | 2 +- .../Services/Social/LinkedInPublisherTest.php | 8 ++++-- .../Services/Social/XPublisherTest.php | 25 +++++++++++++++++++ tests/Feature/Services/XPublisherTest.php | 8 ++++-- 20 files changed, 61 insertions(+), 35 deletions(-) diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index d1fed6b6..17331459 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -86,13 +86,6 @@ protected function isTokenExpired(): Attribute ); } - protected function isTokenExpiringSoon(): Attribute - { - return Attribute::make( - get: fn () => $this->token_expires_at && $this->token_expires_at->isBefore(now()->addMinutes(15)), - ); - } - protected function avatarUrl(): Attribute { return Attribute::make( diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index 5fd6f5e7..527f98be 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -66,7 +66,7 @@ public function publish(PostPlatform $postPlatform): array $this->account = $postPlatform->socialAccount; $this->hasRetried = false; - if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) { + if ($this->account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($this->account); } diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 90a36c6f..01db5938 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -48,7 +48,7 @@ public function publish(PostPlatform $postPlatform): array $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); // Refresh token if needed - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/InstagramAnalytics.php b/app/Services/Social/InstagramAnalytics.php index 50c20c88..87dd9864 100644 --- a/app/Services/Social/InstagramAnalytics.php +++ b/app/Services/Social/InstagramAnalytics.php @@ -44,7 +44,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array $this->baseUrl = $account->platform->instagramGraphBaseUrl(); - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -87,7 +87,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si { $this->baseUrl = $account->platform->instagramGraphBaseUrl(); - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 23e684d8..ca6eb0f4 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -28,7 +28,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; $this->baseUrl = $account->platform->instagramGraphBaseUrl(); - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/LinkedInPageAnalytics.php b/app/Services/Social/LinkedInPageAnalytics.php index 71fb4355..e20651f7 100644 --- a/app/Services/Social/LinkedInPageAnalytics.php +++ b/app/Services/Social/LinkedInPageAnalytics.php @@ -50,7 +50,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -79,7 +79,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/PinterestAnalytics.php b/app/Services/Social/PinterestAnalytics.php index 082963a2..6bb04561 100644 --- a/app/Services/Social/PinterestAnalytics.php +++ b/app/Services/Social/PinterestAnalytics.php @@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -90,7 +90,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index c0669c53..c17114f3 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -34,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -407,7 +407,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId, */ public function getBoards(SocialAccount $account): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ThreadsAnalytics.php b/app/Services/Social/ThreadsAnalytics.php index ef196477..10a2b2a5 100644 --- a/app/Services/Social/ThreadsAnalytics.php +++ b/app/Services/Social/ThreadsAnalytics.php @@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -77,7 +77,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index fe256fa3..36c08916 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -30,7 +30,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index 37861978..3ca14bbf 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -35,7 +35,7 @@ public function getMetrics(SocialAccount $account): array private function fetchMetricsFromApi(SocialAccount $account): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokCreatorInfo.php b/app/Services/Social/TikTokCreatorInfo.php index 7571d067..1a26ef0c 100644 --- a/app/Services/Social/TikTokCreatorInfo.php +++ b/app/Services/Social/TikTokCreatorInfo.php @@ -58,7 +58,7 @@ public function fetch(SocialAccount $account): array */ private function fetchFresh(SocialAccount $account): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 13bf6991..14c8a068 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -43,7 +43,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/XAnalytics.php b/app/Services/Social/XAnalytics.php index c562b2a8..052ffd80 100644 --- a/app/Services/Social/XAnalytics.php +++ b/app/Services/Social/XAnalytics.php @@ -46,7 +46,7 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -161,7 +161,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 5a535605..5c7cbec7 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -37,8 +37,8 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - // Refresh token if expired or expiring soon - if ($account->is_token_expired || $account->is_token_expiring_soon) { + // Refresh only when the token is actually expired + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php index c4eb8342..c5dd3e69 100644 --- a/app/Services/Social/YouTubeAnalytics.php +++ b/app/Services/Social/YouTubeAnalytics.php @@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -97,7 +97,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 5b4467c5..667e613b 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/tests/Feature/Services/Social/LinkedInPublisherTest.php b/tests/Feature/Services/Social/LinkedInPublisherTest.php index 23025e4d..a08c6899 100644 --- a/tests/Feature/Services/Social/LinkedInPublisherTest.php +++ b/tests/Feature/Services/Social/LinkedInPublisherTest.php @@ -804,11 +804,12 @@ protected function processingPollSeconds(): int ->toThrow(TokenExpiredException::class); }); -test('linkedin publisher refreshes the token when it is expiring soon', function () { +test('linkedin publisher does NOT rotate the token when it is only expiring soon but still valid', function () { $this->socialAccount->update([ 'token_expires_at' => now()->addMinutes(5), 'refresh_token' => 'refresh-token-123', ]); + $originalAccessToken = $this->socialAccount->access_token; Http::fake([ config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([ @@ -822,8 +823,11 @@ protected function processingPollSeconds(): int $result = $this->publisher->publish($this->postPlatform); expect($result['id'])->toBe('urn:li:share:soon'); + + // A still-valid token is used as-is — the single-use refresh_token is not rotated. + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken')); $this->socialAccount->refresh(); - expect($this->socialAccount->access_token)->toBe('new-access-token'); + expect($this->socialAccount->access_token)->toBe($originalAccessToken); }); test('linkedin publisher falls back to an empty id and null url when the post id header is missing', function () { diff --git a/tests/Feature/Services/Social/XPublisherTest.php b/tests/Feature/Services/Social/XPublisherTest.php index 9847eaa5..b684199a 100644 --- a/tests/Feature/Services/Social/XPublisherTest.php +++ b/tests/Feature/Services/Social/XPublisherTest.php @@ -65,6 +65,31 @@ }); }); +test('x publisher does NOT rotate the token when it is only expiring soon but still valid', function () { + $this->socialAccount->update([ + 'token_expires_at' => now()->addMinutes(5), + 'refresh_token' => 'original-refresh-token', + ]); + $originalAccessToken = $this->socialAccount->access_token; + + Http::fake([ + config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '999']], 200), + config('trypost.platforms.x.api').'/oauth2/token' => Http::response([ + 'access_token' => 'should-not-be-used', + 'refresh_token' => 'should-not-be-used', + 'expires_in' => 7200, + ], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + // X single-use refresh tokens: a still-valid access_token must NOT be rotated. + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/oauth2/token')); + $this->socialAccount->refresh(); + expect($this->socialAccount->access_token)->toBe($originalAccessToken); + expect($this->socialAccount->refresh_token)->toBe('original-refresh-token'); +}); + test('x publisher uses bearer token authentication', function () { Http::fake([ 'https://api.x.com/2/tweets' => Http::response([ diff --git a/tests/Feature/Services/XPublisherTest.php b/tests/Feature/Services/XPublisherTest.php index 24bae974..13dedfb1 100644 --- a/tests/Feature/Services/XPublisherTest.php +++ b/tests/Feature/Services/XPublisherTest.php @@ -143,11 +143,12 @@ ->toThrow(TokenExpiredException::class); }); -test('x publisher refreshes token when expiring soon', function () { +test('x publisher does NOT rotate the token when it is only expiring soon but still valid', function () { $this->socialAccount->update([ 'token_expires_at' => now()->addMinutes(5), 'refresh_token' => 'refresh-token-123', ]); + $originalAccessToken = $this->socialAccount->access_token; Http::fake([ '*/2/oauth2/token' => Http::response([ @@ -164,8 +165,11 @@ $result = $publisher->publish($this->postPlatform); expect($result['id'])->toBe('tweet-123'); + + // A still-valid token is used as-is — the single-use refresh_token is not rotated. + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/2/oauth2/token')); $this->socialAccount->refresh(); - expect($this->socialAccount->access_token)->toBe('new-access-token'); + expect($this->socialAccount->access_token)->toBe($originalAccessToken); }); test('x publisher handles 403 error as generic error', function () { From 1bb67b7abb3d55c8d4863e7439e4acec09ba5e93 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 09:18:45 -0300 Subject: [PATCH 03/11] Keep Instagram/Threads tokens extended while still valid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Enums/SocialAccount/Platform.php | 16 +++++++++++ app/Jobs/RefreshSocialToken.php | 9 +++++- app/Models/SocialAccount.php | 20 +++++++++++++ .../Social/AbstractLinkedInPublisher.php | 2 +- app/Services/Social/BlueskyPublisher.php | 2 +- app/Services/Social/InstagramAnalytics.php | 4 +-- app/Services/Social/InstagramPublisher.php | 2 +- app/Services/Social/LinkedInPageAnalytics.php | 4 +-- app/Services/Social/PinterestAnalytics.php | 4 +-- app/Services/Social/PinterestPublisher.php | 4 +-- app/Services/Social/ThreadsAnalytics.php | 4 +-- app/Services/Social/ThreadsPublisher.php | 2 +- app/Services/Social/TikTokAnalytics.php | 2 +- app/Services/Social/TikTokCreatorInfo.php | 2 +- app/Services/Social/TikTokPublisher.php | 2 +- app/Services/Social/XAnalytics.php | 4 +-- app/Services/Social/XPublisher.php | 3 +- app/Services/Social/YouTubeAnalytics.php | 4 +-- app/Services/Social/YouTubePublisher.php | 2 +- tests/Feature/Jobs/RefreshSocialTokenTest.php | 25 +++++++++++++++++ tests/Feature/SocialAccountModelTest.php | 28 +++++++++++++++++++ 21 files changed, 120 insertions(+), 25 deletions(-) diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index e66e2164..3ca8f5a7 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -261,6 +261,22 @@ public function requiresContent(): bool }; } + /** + * Whether this platform refreshes by extending the access_token itself + * (Instagram/Threads long-lived tokens) instead of exchanging a separate + * refresh_token. Extension-model tokens cannot be refreshed once expired, + * so they must be refreshed proactively while still valid — the opposite + * of rotating refresh_token platforms, which we avoid refreshing until + * they actually expire so we don't rotate a still-valid single-use token. + */ + public function extendsAccessTokenOnRefresh(): bool + { + return match ($this) { + self::Instagram, self::Threads => true, + default => false, + }; + } + public function queue(): string { return 'social-'.$this->value; diff --git a/app/Jobs/RefreshSocialToken.php b/app/Jobs/RefreshSocialToken.php index 53df7da8..c3678c96 100644 --- a/app/Jobs/RefreshSocialToken.php +++ b/app/Jobs/RefreshSocialToken.php @@ -24,7 +24,14 @@ public function __construct(public SocialAccount $account) {} public function handle(ConnectionVerifier $verifier): void { try { - $verifier->verify($this->account); + if ($this->account->platform->extendsAccessTokenOnRefresh()) { + // Instagram/Threads extend the long-lived token itself and + // can't be refreshed once expired, so extend it while it's + // still valid instead of waiting for it to fail. + $verifier->refreshToken($this->account); + } else { + $verifier->verify($this->account); + } } catch (PlatformUnavailableException $e) { Log::warning('Token refresh skipped: platform unavailable', [ 'account_id' => $this->account->id, diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 17331459..21a3f573 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -86,6 +86,26 @@ protected function isTokenExpired(): Attribute ); } + protected function isTokenExpiringSoon(): Attribute + { + return Attribute::make( + get: fn () => $this->token_expires_at && $this->token_expires_at->isBefore(now()->addMinutes(15)), + ); + } + + /** + * Whether the token should be refreshed before use. Rotating-refresh-token + * platforms are only refreshed once actually expired, to avoid rotating a + * still-valid single-use refresh_token; extension-model platforms + * (Instagram/Threads) must be refreshed while still valid because their + * token can't be extended once expired. + */ + public function needsProactiveTokenRefresh(): bool + { + return $this->is_token_expired + || ($this->platform->extendsAccessTokenOnRefresh() && $this->is_token_expiring_soon); + } + protected function avatarUrl(): Attribute { return Attribute::make( diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index 527f98be..febc7858 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -66,7 +66,7 @@ public function publish(PostPlatform $postPlatform): array $this->account = $postPlatform->socialAccount; $this->hasRetried = false; - if ($this->account->is_token_expired) { + if ($this->account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($this->account); } diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 01db5938..15d4850d 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -48,7 +48,7 @@ public function publish(PostPlatform $postPlatform): array $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); // Refresh token if needed - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/InstagramAnalytics.php b/app/Services/Social/InstagramAnalytics.php index 87dd9864..42356e1e 100644 --- a/app/Services/Social/InstagramAnalytics.php +++ b/app/Services/Social/InstagramAnalytics.php @@ -44,7 +44,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array $this->baseUrl = $account->platform->instagramGraphBaseUrl(); - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -87,7 +87,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si { $this->baseUrl = $account->platform->instagramGraphBaseUrl(); - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index ca6eb0f4..176bdcc3 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -28,7 +28,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; $this->baseUrl = $account->platform->instagramGraphBaseUrl(); - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/LinkedInPageAnalytics.php b/app/Services/Social/LinkedInPageAnalytics.php index e20651f7..5573a749 100644 --- a/app/Services/Social/LinkedInPageAnalytics.php +++ b/app/Services/Social/LinkedInPageAnalytics.php @@ -50,7 +50,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -79,7 +79,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/PinterestAnalytics.php b/app/Services/Social/PinterestAnalytics.php index 6bb04561..7b3bc6ce 100644 --- a/app/Services/Social/PinterestAnalytics.php +++ b/app/Services/Social/PinterestAnalytics.php @@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -90,7 +90,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index c17114f3..b56f0b97 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -34,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -407,7 +407,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId, */ public function getBoards(SocialAccount $account): array { - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ThreadsAnalytics.php b/app/Services/Social/ThreadsAnalytics.php index 10a2b2a5..67c572f6 100644 --- a/app/Services/Social/ThreadsAnalytics.php +++ b/app/Services/Social/ThreadsAnalytics.php @@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -77,7 +77,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index 36c08916..d3740bd1 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -30,7 +30,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index 3ca14bbf..7cc7d5e0 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -35,7 +35,7 @@ public function getMetrics(SocialAccount $account): array private function fetchMetricsFromApi(SocialAccount $account): array { - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokCreatorInfo.php b/app/Services/Social/TikTokCreatorInfo.php index 1a26ef0c..ce058674 100644 --- a/app/Services/Social/TikTokCreatorInfo.php +++ b/app/Services/Social/TikTokCreatorInfo.php @@ -58,7 +58,7 @@ public function fetch(SocialAccount $account): array */ private function fetchFresh(SocialAccount $account): array { - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 14c8a068..0c50fa17 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -43,7 +43,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/XAnalytics.php b/app/Services/Social/XAnalytics.php index 052ffd80..618a87ef 100644 --- a/app/Services/Social/XAnalytics.php +++ b/app/Services/Social/XAnalytics.php @@ -46,7 +46,7 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -161,7 +161,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 5c7cbec7..6b82f1a7 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -37,8 +37,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - // Refresh only when the token is actually expired - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php index c5dd3e69..f43b16db 100644 --- a/app/Services/Social/YouTubeAnalytics.php +++ b/app/Services/Social/YouTubeAnalytics.php @@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -97,7 +97,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 667e613b..f3f6e443 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index 6299504e..e9e44399 100644 --- a/tests/Feature/Jobs/RefreshSocialTokenTest.php +++ b/tests/Feature/Jobs/RefreshSocialTokenTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status; use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; @@ -60,6 +61,30 @@ expect($this->account->fresh()->status)->toBe(Status::Connected); }); +test('proactive refresh EXTENDS a still-valid Instagram token (extension-model platform)', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Instagram, + 'status' => Status::Connected, + 'access_token' => 'old-ig-token', + 'token_expires_at' => now()->addMinutes(20), + ]); + + Http::fake([ + config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([ + 'access_token' => 'extended-ig-token', + 'expires_in' => 5184000, + ], 200), + ]); + + (new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class)); + + // Instagram/Threads extend the token itself and can't refresh once expired, + // so a still-valid token IS extended proactively — unlike rotating platforms. + Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token')); + expect($account->fresh()->access_token)->toBe('extended-ig-token'); +}); + test('refresh job marks account as TokenExpired when refresh_token is rejected', function () { Queue::fake(); diff --git a/tests/Feature/SocialAccountModelTest.php b/tests/Feature/SocialAccountModelTest.php index d28bbf70..9be609c7 100644 --- a/tests/Feature/SocialAccountModelTest.php +++ b/tests/Feature/SocialAccountModelTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\Notification\Type; +use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status; use App\Events\NotificationCreated; use App\Jobs\SendNotification; @@ -20,6 +21,33 @@ $this->workspace = Workspace::factory()->create(['user_id' => $this->owner->id]); }); +// ---- needsProactiveTokenRefresh ---- + +test('needsProactiveTokenRefresh: rotating platforms refresh only when expired, extension platforms while still valid', function () { + // Rotating (X): a still-valid token that is merely expiring soon is NOT refreshed. + $xValid = SocialAccount::factory()->x()->create([ + 'workspace_id' => $this->workspace->id, + 'token_expires_at' => now()->addMinutes(10), + ]); + expect($xValid->needsProactiveTokenRefresh())->toBeFalse(); + + // Rotating (X): once actually expired, it is refreshed. + $xExpired = SocialAccount::factory()->x()->create([ + 'workspace_id' => $this->workspace->id, + 'token_expires_at' => now()->subMinute(), + ]); + expect($xExpired->needsProactiveTokenRefresh())->toBeTrue(); + + // Extension-model (Instagram): a still-valid token that is expiring soon MUST + // be refreshed while valid — its token can't be extended once expired. + $igValid = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Instagram, + 'token_expires_at' => now()->addMinutes(10), + ]); + expect($igValid->needsProactiveTokenRefresh())->toBeTrue(); +}); + // ---- markAsTokenExpired ---- test('markAsTokenExpired updates status and dispatches notification when transitioning from connected', function () { From 6037169aa9639bfed77cc45275ed7fa3dbfe7b84 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 09:58:26 -0300 Subject: [PATCH 04/11] Cover Threads token extension with an explicit test 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 --- tests/Feature/Jobs/RefreshSocialTokenTest.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index e9e44399..bf9d0269 100644 --- a/tests/Feature/Jobs/RefreshSocialTokenTest.php +++ b/tests/Feature/Jobs/RefreshSocialTokenTest.php @@ -85,6 +85,28 @@ expect($account->fresh()->access_token)->toBe('extended-ig-token'); }); +test('proactive refresh EXTENDS a still-valid Threads token (extension-model platform)', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Threads, + 'status' => Status::Connected, + 'access_token' => 'old-threads-token', + 'token_expires_at' => now()->addMinutes(20), + ]); + + Http::fake([ + config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([ + 'access_token' => 'extended-threads-token', + 'expires_in' => 5184000, + ], 200), + ]); + + (new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class)); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token')); + expect($account->fresh()->access_token)->toBe('extended-threads-token'); +}); + test('refresh job marks account as TokenExpired when refresh_token is rejected', function () { Queue::fake(); From 94d0156ec0cfef52f2c01be9575ad6711d516ad8 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 10:33:26 -0300 Subject: [PATCH 05/11] Give Instagram/Threads a wider proactive-refresh window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Commands/RefreshExpiringTokens.php | 20 ++++- app/Enums/SocialAccount/Platform.php | 16 ++++ .../Commands/RefreshExpiringTokensTest.php | 84 +++++++++++++++---- 3 files changed, 104 insertions(+), 16 deletions(-) diff --git a/app/Console/Commands/RefreshExpiringTokens.php b/app/Console/Commands/RefreshExpiringTokens.php index 981fe1b1..38b6b689 100644 --- a/app/Console/Commands/RefreshExpiringTokens.php +++ b/app/Console/Commands/RefreshExpiringTokens.php @@ -4,17 +4,25 @@ namespace App\Console\Commands; +use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status; use App\Jobs\RefreshSocialToken; use App\Models\SocialAccount; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Builder; class RefreshExpiringTokens extends Command { protected $signature = 'social:refresh-expiring-tokens'; - protected $description = 'Proactively refresh tokens expiring in the next 30 minutes (or already expired)'; + protected $description = 'Proactively refresh social tokens before they expire'; + /** + * Rotating refresh_token platforms only need a short lead: verify() won't + * rotate a still-valid token, so we catch them right before or after expiry. + * Extension-model platforms (Instagram/Threads) can't be refreshed once + * expired, so they get a much wider lead to survive queue backlog. + */ public function handle(): void { $count = 0; @@ -22,7 +30,15 @@ public function handle(): void SocialAccount::query() ->where('status', Status::Connected) ->whereNotNull('token_expires_at') - ->where('token_expires_at', '<=', now()->addMinutes(30)) + ->where(function (Builder $query) { + $query->where(function (Builder $extension) { + $extension->whereIn('platform', Platform::extensionModelValues()) + ->where('token_expires_at', '<=', now()->addDay()); + })->orWhere(function (Builder $rotating) { + $rotating->whereNotIn('platform', Platform::extensionModelValues()) + ->where('token_expires_at', '<=', now()->addMinutes(30)); + }); + }) ->chunk(50, function ($accounts) use (&$count) { foreach ($accounts as $account) { RefreshSocialToken::dispatch($account); diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 3ca8f5a7..59e7115a 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -277,6 +277,22 @@ public function extendsAccessTokenOnRefresh(): bool }; } + /** + * All platform values that refresh by extending the access_token itself + * (see extendsAccessTokenOnRefresh). Because these can't be refreshed once + * expired, the proactive-refresh cron gives them a wider window than + * rotating platforms so a queue backlog can't let them lapse. + * + * @return array + */ + public static function extensionModelValues(): array + { + return array_values(array_map( + fn (self $platform): string => $platform->value, + array_filter(self::cases(), fn (self $platform): bool => $platform->extendsAccessTokenOnRefresh()), + )); + } + public function queue(): string { return 'social-'.$this->value; diff --git a/tests/Feature/Commands/RefreshExpiringTokensTest.php b/tests/Feature/Commands/RefreshExpiringTokensTest.php index ea8b8019..957f8f7f 100644 --- a/tests/Feature/Commands/RefreshExpiringTokensTest.php +++ b/tests/Feature/Commands/RefreshExpiringTokensTest.php @@ -9,48 +9,73 @@ use App\Models\Workspace; use Illuminate\Support\Facades\Queue; -test('it dispatches refresh jobs for tokens expiring within 30 minutes or already expired', function () { +test('it dispatches refresh jobs for rotating tokens near expiry and extension tokens well ahead of expiry', function () { Queue::fake(); $workspace = Workspace::factory()->create(); - // Should be refreshed (expires in 15 minutes — inside the proactive window) - $expiringSoon = SocialAccount::factory()->create([ + // Rotating platform expiring in 15 minutes — inside the 30-minute window. + $rotatingSoon = SocialAccount::factory()->create([ 'workspace_id' => $workspace->id, 'platform' => Platform::LinkedIn, 'status' => Status::Connected, 'token_expires_at' => now()->addMinutes(15), ]); - // Should NOT be refreshed (expires in 1 hour — outside the proactive window) + // Rotating platform expiring in 1 hour — OUTSIDE the 30-minute window. SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::X, + 'status' => Status::Connected, + 'token_expires_at' => now()->addHour(), + ]); + + // Extension platform expiring in 1 hour — inside the wide 24-hour window. + // (On the old shared 30-minute window this lapsed under queue backlog.) + $extensionSoon = SocialAccount::factory()->create([ 'workspace_id' => $workspace->id, 'platform' => Platform::Instagram, 'status' => Status::Connected, 'token_expires_at' => now()->addHour(), ]); - // SHOULD be refreshed (already expired — last-chance attempt before the - // refresh_token also dies at the provider). - $justExpired = SocialAccount::factory()->create([ + // Extension platform expiring in 12 hours — still inside the 24-hour window. + $extensionLater = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::Threads, + 'status' => Status::Connected, + 'token_expires_at' => now()->addHours(12), + ]); + + // Extension platform expiring in 2 days — OUTSIDE the 24-hour window. + SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::Instagram, + 'status' => Status::Connected, + 'token_expires_at' => now()->addDays(2), + ]); + + // Rotating platform already expired — last-chance attempt before the + // refresh_token also dies at the provider. + $rotatingExpired = SocialAccount::factory()->create([ 'workspace_id' => $workspace->id, 'platform' => Platform::TikTok, 'status' => Status::Connected, 'token_expires_at' => now()->subHour(), ]); - // Should NOT be refreshed (disconnected) + // Disconnected — never refreshed. SocialAccount::factory()->create([ 'workspace_id' => $workspace->id, - 'platform' => Platform::X, + 'platform' => Platform::Pinterest, 'status' => Status::Disconnected, 'token_expires_at' => now()->addHour(), ]); - // Should NOT be refreshed (already token expired — daily verify handles these) + // Already token expired — daily verify handles these. SocialAccount::factory()->create([ 'workspace_id' => $workspace->id, - 'platform' => Platform::Pinterest, + 'platform' => Platform::YouTube, 'status' => Status::TokenExpired, 'token_expires_at' => now()->subHour(), ]); @@ -58,9 +83,40 @@ $this->artisan('social:refresh-expiring-tokens') ->assertSuccessful(); - Queue::assertPushed(RefreshSocialToken::class, 2); - Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $expiringSoon->id); - Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $justExpired->id); + Queue::assertPushed(RefreshSocialToken::class, 4); + Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotatingSoon->id); + Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extensionSoon->id); + Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extensionLater->id); + Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotatingExpired->id); +}); + +test('extension-model platforms get a wider refresh window than rotating platforms', function () { + Queue::fake(); + + $workspace = Workspace::factory()->create(); + + // Same expiry (1 hour out) for both — only the extension-model account + // should be dispatched, because it can't be refreshed once expired. + $extension = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::Instagram, + 'status' => Status::Connected, + 'token_expires_at' => now()->addHour(), + ]); + + $rotating = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::X, + 'status' => Status::Connected, + 'token_expires_at' => now()->addHour(), + ]); + + $this->artisan('social:refresh-expiring-tokens') + ->assertSuccessful(); + + Queue::assertPushed(RefreshSocialToken::class, 1); + Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extension->id); + Queue::assertNotPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotating->id); }); test('it dispatches nothing when no tokens are expiring', function () { From 545a7798487e055922999a23428d05dddabc4f00 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 10:33:26 -0300 Subject: [PATCH 06/11] Never leave Instagram/Threads with a null token expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Controllers/Auth/ThreadsController.php | 19 ++--- app/Services/Social/ConnectionVerifier.php | 4 +- .../Social/ConnectionVerifierTest.php | 36 ++++++++++ .../Feature/Social/ThreadsControllerTest.php | 72 +++++++++++++++++++ 4 files changed, 121 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index c8445f7a..f4ea63ff 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -106,15 +106,18 @@ public function callback(Request $request): InertiaResponse 'access_token' => $shortLivedToken, ]); - $longLivedToken = $shortLivedToken; - $expiresIn = null; - - if ($longLivedResponse->successful()) { - $longLivedData = $longLivedResponse->json(); - $longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken; - $expiresIn = $longLivedData['expires_in'] ?? null; + if ($longLivedResponse->failed()) { + Log::error('Threads long-lived token exchange failed', [ + 'status' => $longLivedResponse->status(), + 'body' => $longLivedResponse->body(), + ]); + throw new \Exception('Failed to exchange long-lived token'); } + $longLivedData = $longLivedResponse->json(); + $longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken; + $expiresIn = $longLivedData['expires_in'] ?? 5184000; + // Fetch user profile $profileResponse = Http::get(config('trypost.platforms.threads.graph_api')."/{$userId}", [ 'access_token' => $longLivedToken, @@ -142,7 +145,7 @@ public function callback(Request $request): InertiaResponse 'avatar_url' => $avatarPath, 'access_token' => $longLivedToken, 'refresh_token' => null, - 'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null, + 'token_expires_at' => now()->addSeconds($expiresIn), 'scopes' => $this->scopes, 'status' => Status::Connected, 'error_message' => null, diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 4cb29072..60513199 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -322,7 +322,7 @@ private function refreshThreadsToken(SocialAccount $account): void $account->update([ 'access_token' => $newToken, 'refresh_token' => $newToken, - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 5184000)), ]); $account->refresh(); @@ -341,7 +341,7 @@ private function refreshInstagramToken(SocialAccount $account): void $account->update([ 'access_token' => $newToken, 'refresh_token' => $newToken, - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 5184000)), ]); $account->refresh(); diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 74d1bbe9..e5472b44 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -284,6 +284,42 @@ Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token')); }); +test('threads refresh records a 60-day expiry when the response omits expires_in', function () { + Http::fake([ + config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([ + 'access_token' => 'new-threads-token', + ], 200), + ]); + + $account = SocialAccount::factory()->threads()->create([ + 'token_expires_at' => now()->addHours(2), + ]); + + (new ConnectionVerifier)->refreshToken($account); + + $account->refresh(); + expect($account->token_expires_at)->not->toBeNull(); + expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue(); +}); + +test('instagram refresh records a 60-day expiry when the response omits expires_in', function () { + Http::fake([ + config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([ + 'access_token' => 'new-instagram-token', + ], 200), + ]); + + $account = SocialAccount::factory()->instagram()->create([ + 'token_expires_at' => now()->addHours(2), + ]); + + (new ConnectionVerifier)->refreshToken($account); + + $account->refresh(); + expect($account->token_expires_at)->not->toBeNull(); + expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue(); +}); + test('does NOT refresh proactively when token still works (lazy refresh)', function () { Http::fake([ 'api.linkedin.com/*' => Http::response(['sub' => '123'], 200), diff --git a/tests/Feature/Social/ThreadsControllerTest.php b/tests/Feature/Social/ThreadsControllerTest.php index 19f900f1..3ccd96a4 100644 --- a/tests/Feature/Social/ThreadsControllerTest.php +++ b/tests/Feature/Social/ThreadsControllerTest.php @@ -217,3 +217,75 @@ expect($this->workspace->socialAccounts()->where('platform', Platform::Threads)->count())->toBe(1); }); + +test('threads callback fails the connect when the long-lived token exchange fails', function () { + $state = bin2hex(random_bytes(16)); + + session([ + 'social_connect_workspace' => $this->workspace->id, + 'threads_oauth_state' => $state, + ]); + + Http::fake([ + config('trypost.platforms.threads.auth_api').'/oauth/access_token' => Http::response([ + 'access_token' => 'short-lived-token', + 'user_id' => '123456789', + ], 200), + config('trypost.platforms.threads.auth_api').'/access_token*' => Http::response('upstream error', 503), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.threads.callback', [ + 'code' => 'test-auth-code', + 'state' => $state, + ])); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)); + $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.error_connecting'))); + + // A short-lived-only account would silently die within the hour and never be + // picked up by the refresh cron, so the connect must not persist one. + $this->assertDatabaseMissing('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Threads->value, + ]); +}); + +test('threads callback records a 60-day expiry when the long-lived exchange omits expires_in', function () { + $state = bin2hex(random_bytes(16)); + + session([ + 'social_connect_workspace' => $this->workspace->id, + 'threads_oauth_state' => $state, + ]); + + Http::fake([ + config('trypost.platforms.threads.auth_api').'/oauth/access_token' => Http::response([ + 'access_token' => 'short-lived-token', + 'user_id' => '123456789', + ], 200), + config('trypost.platforms.threads.auth_api').'/access_token*' => Http::response([ + 'access_token' => 'long-lived-token', + ], 200), + config('trypost.platforms.threads.graph_api').'/123456789*' => Http::response([ + 'id' => '123456789', + 'username' => 'testuser', + 'name' => 'Test User', + ], 200), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.threads.callback', [ + 'code' => 'test-auth-code', + 'state' => $state, + ])); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true)); + + $account = $this->workspace->socialAccounts() + ->where('platform', Platform::Threads) + ->first(); + + expect($account->token_expires_at)->not->toBeNull(); + expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue(); +}); From 4d5ca6b274bbbf290f116f2f3e784b3b5056c600 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 11:07:54 -0300 Subject: [PATCH 07/11] Centralize the Meta long-lived token TTL and clarify the extension helper 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. --- app/Console/Commands/RefreshExpiringTokens.php | 4 ++-- app/Enums/SocialAccount/Platform.php | 17 ++++++++++++----- .../Controllers/Auth/InstagramController.php | 2 +- app/Http/Controllers/Auth/ThreadsController.php | 2 +- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/app/Console/Commands/RefreshExpiringTokens.php b/app/Console/Commands/RefreshExpiringTokens.php index 38b6b689..9dc08b66 100644 --- a/app/Console/Commands/RefreshExpiringTokens.php +++ b/app/Console/Commands/RefreshExpiringTokens.php @@ -32,10 +32,10 @@ public function handle(): void ->whereNotNull('token_expires_at') ->where(function (Builder $query) { $query->where(function (Builder $extension) { - $extension->whereIn('platform', Platform::extensionModelValues()) + $extension->whereIn('platform', Platform::accessTokenExtendingPlatformValues()) ->where('token_expires_at', '<=', now()->addDay()); })->orWhere(function (Builder $rotating) { - $rotating->whereNotIn('platform', Platform::extensionModelValues()) + $rotating->whereNotIn('platform', Platform::accessTokenExtendingPlatformValues()) ->where('token_expires_at', '<=', now()->addMinutes(30)); }); }) diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 59e7115a..808f865b 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -23,6 +23,13 @@ enum Platform: string case Telegram = 'telegram'; case Discord = 'discord'; + /** + * Fallback lifetime, in seconds, for Meta long-lived tokens (Instagram and + * Threads — see extendsAccessTokenOnRefresh) when the provider response + * omits expires_in. Meta issues these tokens for 60 days. + */ + public const LONG_LIVED_TOKEN_TTL_SECONDS = 5184000; + /** * The social network this platform belongs to. Variants that represent the * same network (LinkedIn profile vs. company page, Instagram standalone vs. @@ -278,14 +285,14 @@ public function extendsAccessTokenOnRefresh(): bool } /** - * All platform values that refresh by extending the access_token itself - * (see extendsAccessTokenOnRefresh). Because these can't be refreshed once - * expired, the proactive-refresh cron gives them a wider window than - * rotating platforms so a queue backlog can't let them lapse. + * The `platform` column values of the platforms that refresh by extending + * their access token in place (Instagram and Threads — see + * extendsAccessTokenOnRefresh), for use in database whereIn/whereNotIn + * filters. Derived from extendsAccessTokenOnRefresh() so the two never drift. * * @return array */ - public static function extensionModelValues(): array + public static function accessTokenExtendingPlatformValues(): array { return array_values(array_map( fn (self $platform): string => $platform->value, diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php index 79931649..af48218c 100644 --- a/app/Http/Controllers/Auth/InstagramController.php +++ b/app/Http/Controllers/Auth/InstagramController.php @@ -69,7 +69,7 @@ public function callback(Request $request): InertiaResponse $avatarPath = $socialUser->getAvatar() ? uploadFromUrl($socialUser->getAvatar()) : null; // Calculate token expiration (long-lived tokens last 60 days) - $expiresIn = $socialUser->expiresIn ?? 5184000; // 60 days in seconds + $expiresIn = $socialUser->expiresIn ?? SocialPlatform::LONG_LIVED_TOKEN_TTL_SECONDS; $tokenExpiresAt = now()->addSeconds($expiresIn); $workspace->socialAccounts()->updateOrCreate( diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index f4ea63ff..6b29e568 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -116,7 +116,7 @@ public function callback(Request $request): InertiaResponse $longLivedData = $longLivedResponse->json(); $longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken; - $expiresIn = $longLivedData['expires_in'] ?? 5184000; + $expiresIn = $longLivedData['expires_in'] ?? SocialPlatform::LONG_LIVED_TOKEN_TTL_SECONDS; // Fetch user profile $profileResponse = Http::get(config('trypost.platforms.threads.graph_api')."/{$userId}", [ From 83ba72ddc60916d2aa56932a3b47991f7b885a3d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 11:07:54 -0300 Subject: [PATCH 08/11] Stop Meta rate-limit errors from disconnecting Instagram/Threads tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/Services/Social/ConnectionVerifier.php | 54 +++++++--------- app/Services/Social/Meta/GraphError.php | 30 +++++++++ app/Services/Social/TokenRefreshClient.php | 24 +++++-- tests/Feature/Jobs/RefreshSocialTokenTest.php | 22 +++++++ .../Social/ConnectionVerifierTest.php | 62 +++++++++++++++++++ .../Services/Social/Meta/GraphErrorTest.php | 35 +++++++++++ 6 files changed, 192 insertions(+), 35 deletions(-) create mode 100644 app/Services/Social/Meta/GraphError.php create mode 100644 tests/Unit/Services/Social/Meta/GraphErrorTest.php diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 60513199..b795eed0 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -9,6 +9,7 @@ use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use App\Services\Social\Discord\DiscordClient; +use App\Services\Social\Meta\GraphError; use App\Services\Social\Telegram\TelegramApi; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -311,10 +312,13 @@ private function refreshPinterestToken(SocialAccount $account): void private function refreshThreadsToken(SocialAccount $account): void { // Threads uses long-lived tokens that can be refreshed - $response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ - 'grant_type' => 'th_refresh_token', - 'access_token' => $account->access_token, - ])); + $response = TokenRefreshClient::for(Platform::Threads)->send( + fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ + 'grant_type' => 'th_refresh_token', + 'access_token' => $account->access_token, + ]), + GraphError::indicatesInvalidToken(...), + ); $data = $response->json(); $newToken = data_get($data, 'access_token'); @@ -322,7 +326,7 @@ private function refreshThreadsToken(SocialAccount $account): void $account->update([ 'access_token' => $newToken, 'refresh_token' => $newToken, - 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 5184000)), + 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', Platform::LONG_LIVED_TOKEN_TTL_SECONDS)), ]); $account->refresh(); @@ -330,10 +334,13 @@ private function refreshThreadsToken(SocialAccount $account): void private function refreshInstagramToken(SocialAccount $account): void { - $response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ - 'grant_type' => 'ig_refresh_token', - 'access_token' => $account->access_token, - ])); + $response = TokenRefreshClient::for(Platform::Instagram)->send( + fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ + 'grant_type' => 'ig_refresh_token', + 'access_token' => $account->access_token, + ]), + GraphError::indicatesInvalidToken(...), + ); $data = $response->json(); $newToken = data_get($data, 'access_token'); @@ -341,7 +348,7 @@ private function refreshInstagramToken(SocialAccount $account): void $account->update([ 'access_token' => $newToken, 'refresh_token' => $newToken, - 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 5184000)), + 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', Platform::LONG_LIVED_TOKEN_TTL_SECONDS)), ]); $account->refresh(); @@ -408,13 +415,8 @@ private function verifyInstagram(SocialAccount $account): bool $body = $response->json() ?? []; - if (isset($body['error'])) { - $errorCode = $body['error']['code'] ?? null; - $errorType = $body['error']['type'] ?? null; - - if ($errorType === 'OAuthException' || $errorCode === 190) { - throw new TokenExpiredException('Instagram access token is invalid or expired'); - } + if (GraphError::indicatesInvalidToken($body)) { + throw new TokenExpiredException('Instagram access token is invalid or expired'); } return $response->successful(); @@ -429,13 +431,8 @@ private function verifyFacebook(SocialAccount $account): bool $body = $response->json() ?? []; - if (isset($body['error'])) { - $errorCode = $body['error']['code'] ?? null; - $errorType = $body['error']['type'] ?? null; - - if ($errorType === 'OAuthException' || $errorCode === 190) { - throw new TokenExpiredException('Facebook access token is invalid or expired'); - } + if (GraphError::indicatesInvalidToken($body)) { + throw new TokenExpiredException('Facebook access token is invalid or expired'); } return $response->successful(); @@ -450,13 +447,8 @@ private function verifyThreads(SocialAccount $account): bool $body = $response->json() ?? []; - if (isset($body['error'])) { - $errorCode = $body['error']['code'] ?? null; - $errorType = $body['error']['type'] ?? null; - - if ($errorType === 'OAuthException' || $errorCode === 190) { - throw new TokenExpiredException('Threads access token is invalid or expired'); - } + if (GraphError::indicatesInvalidToken($body)) { + throw new TokenExpiredException('Threads access token is invalid or expired'); } return $response->successful(); diff --git a/app/Services/Social/Meta/GraphError.php b/app/Services/Social/Meta/GraphError.php new file mode 100644 index 00000000..e0097e5b --- /dev/null +++ b/app/Services/Social/Meta/GraphError.php @@ -0,0 +1,30 @@ +|null $body + */ + public static function indicatesInvalidToken(?array $body): bool + { + return data_get($body, 'error.code') === 190; + } +} diff --git a/app/Services/Social/TokenRefreshClient.php b/app/Services/Social/TokenRefreshClient.php index 70b023e2..d20839e4 100644 --- a/app/Services/Social/TokenRefreshClient.php +++ b/app/Services/Social/TokenRefreshClient.php @@ -16,13 +16,18 @@ * Normalizes the failure modes of an OAuth token-refresh request: * * - ConnectionException (timeout / DNS / refused) → PlatformUnavailableException - * - HTTP 5xx → PlatformUnavailableException - * - HTTP 4xx → TokenExpiredException + * - HTTP 5xx or 429 → PlatformUnavailableException + * - other HTTP 4xx → TokenExpiredException + * + * Providers that return rate-limit or transient errors as an ordinary 4xx + * (Meta: Instagram/Threads) can pass an `$isTokenInvalid` classifier to + * `send()`; only a genuinely dead token then disconnects, while every other + * 4xx is treated as transient (PlatformUnavailableException). * * Callers configure the actual HTTP call through the closure passed to * `send()`, so platform-specific quirks (form vs JSON body, auth headers, * basic auth, etc.) stay where they belong — in the per-platform refresh - * method — while the failure semantics are uniform across providers. + * method. */ class TokenRefreshClient { @@ -35,11 +40,14 @@ public static function for(Platform $platform): self /** * @param Closure():Response $request + * @param (Closure(array|null):bool)|null $isTokenInvalid Given the parsed + * response body, returns whether a non-5xx/429 failure means the token itself is dead. + * When it returns false, the failure is treated as transient (PlatformUnavailableException). * * @throws PlatformUnavailableException * @throws TokenExpiredException */ - public function send(Closure $request): Response + public function send(Closure $request, ?Closure $isTokenInvalid = null): Response { $name = $this->platform->label(); @@ -62,6 +70,14 @@ public function send(Closure $request): Response ]); $body = $response->json(); + + if ($isTokenInvalid !== null && ! $isTokenInvalid($body)) { + throw new PlatformUnavailableException( + "{$name} API returned {$response->status()} during token refresh", + $response->status(), + ); + } + $message = data_get($body, 'error_description') ?? data_get($body, 'error.message') ?? "Failed to refresh {$name} token"; diff --git a/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index bf9d0269..898950c3 100644 --- a/tests/Feature/Jobs/RefreshSocialTokenTest.php +++ b/tests/Feature/Jobs/RefreshSocialTokenTest.php @@ -107,6 +107,28 @@ expect($account->fresh()->access_token)->toBe('extended-threads-token'); }); +test('proactive refresh does NOT disconnect Instagram on a Meta rate-limit (400 OAuthException code 4)', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Instagram, + 'status' => Status::Connected, + 'access_token' => 'valid-ig-token', + 'token_expires_at' => now()->addMinutes(20), + ]); + + Http::fake([ + config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([ + 'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4], + ], 400), + ]); + + (new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class)); + + // A rate-limit is transient — the still-valid token must stay Connected. + expect($account->fresh()->status)->toBe(Status::Connected); + expect($account->fresh()->access_token)->toBe('valid-ig-token'); +}); + test('refresh job marks account as TokenExpired when refresh_token is rejected', function () { Queue::fake(); diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index e5472b44..29a38b99 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -561,3 +561,65 @@ expect((new ConnectionVerifier)->verify($account))->toBeFalse(); }); + +test('instagram refresh treats a Meta rate-limit (400 OAuthException code 4) as transient, not a dead token', function () { + Http::fake([ + config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([ + 'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4], + ], 400), + ]); + + $account = SocialAccount::factory()->instagram()->create([ + 'token_expires_at' => now()->subHour(), + ]); + + expect(fn () => (new ConnectionVerifier)->refreshToken($account)) + ->toThrow(PlatformUnavailableException::class); +}); + +test('threads refresh treats a Meta rate-limit (400 OAuthException code 17) as transient, not a dead token', function () { + Http::fake([ + config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([ + 'error' => ['message' => 'User request limit reached', 'type' => 'OAuthException', 'code' => 17], + ], 400), + ]); + + $account = SocialAccount::factory()->threads()->create([ + 'token_expires_at' => now()->subHour(), + ]); + + expect(fn () => (new ConnectionVerifier)->refreshToken($account)) + ->toThrow(PlatformUnavailableException::class); +}); + +test('instagram refresh treats code 190 as a genuinely expired token', function () { + Http::fake([ + config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([ + 'error' => ['message' => 'Access token has expired', 'type' => 'OAuthException', 'code' => 190], + ], 400), + ]); + + $account = SocialAccount::factory()->instagram()->create([ + 'token_expires_at' => now()->subHour(), + ]); + + expect(fn () => (new ConnectionVerifier)->refreshToken($account)) + ->toThrow(TokenExpiredException::class); +}); + +test('instagram verify treats a Meta rate-limit (OAuthException code 4) as still-valid, not a disconnect', function () { + Http::fake([ + config('trypost.platforms.instagram.graph_api').'/me*' => Http::response([ + 'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4], + ], 400), + ]); + + // Not expired, so verify hits the endpoint directly (no refresh). + $account = SocialAccount::factory()->instagram()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + // A rate-limit must NOT raise TokenExpiredException (which would disconnect); + // verify returns false and the caller leaves the account connected. + expect((new ConnectionVerifier)->verify($account))->toBeFalse(); +}); diff --git a/tests/Unit/Services/Social/Meta/GraphErrorTest.php b/tests/Unit/Services/Social/Meta/GraphErrorTest.php new file mode 100644 index 00000000..f1d79931 --- /dev/null +++ b/tests/Unit/Services/Social/Meta/GraphErrorTest.php @@ -0,0 +1,35 @@ + ['message' => 'Access token has expired', 'type' => 'OAuthException', 'code' => 190], + ]))->toBeTrue(); +}); + +test('rate-limit codes carried as OAuthException do NOT indicate an invalid token', function () { + // Meta returns rate limits as HTTP 4xx with type OAuthException — they must + // stay transient so a throttle never disconnects a still-valid token. + expect(GraphError::indicatesInvalidToken([ + 'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4], + ]))->toBeFalse(); + + expect(GraphError::indicatesInvalidToken([ + 'error' => ['message' => 'User request limit reached', 'type' => 'OAuthException', 'code' => 17], + ]))->toBeFalse(); +}); + +test('transient codes do NOT indicate an invalid token', function () { + expect(GraphError::indicatesInvalidToken([ + 'error' => ['message' => 'Service temporarily unavailable', 'code' => 2], + ]))->toBeFalse(); +}); + +test('a body with no error, or a null body, does not indicate an invalid token', function () { + expect(GraphError::indicatesInvalidToken(null))->toBeFalse(); + expect(GraphError::indicatesInvalidToken([]))->toBeFalse(); + expect(GraphError::indicatesInvalidToken(['data' => ['id' => '123']]))->toBeFalse(); +}); From 3d3150bd2008e9e1791198bee58718edd89aeb28 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 11:26:48 -0300 Subject: [PATCH 09/11] Redact the Threads token-exchange failure logs 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. --- app/Http/Controllers/Auth/ThreadsController.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index 6b29e568..059b173e 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -8,6 +8,7 @@ use App\Enums\SocialAccount\Status; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\Workspace; +use App\Services\Social\TokenRedactor; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -90,7 +91,7 @@ public function callback(Request $request): InertiaResponse if ($tokenResponse->failed()) { Log::error('Threads token exchange failed', [ 'status' => $tokenResponse->status(), - 'body' => $tokenResponse->body(), + 'body' => TokenRedactor::redact($tokenResponse->body()), ]); throw new \Exception('Failed to exchange token'); } @@ -109,7 +110,7 @@ public function callback(Request $request): InertiaResponse if ($longLivedResponse->failed()) { Log::error('Threads long-lived token exchange failed', [ 'status' => $longLivedResponse->status(), - 'body' => $longLivedResponse->body(), + 'body' => TokenRedactor::redact($longLivedResponse->body()), ]); throw new \Exception('Failed to exchange long-lived token'); } From ba8068f517d947345d8a4034c814835cfbf6b5cf Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 11:39:48 -0300 Subject: [PATCH 10/11] Recover from a lost-rotation race on the post-refresh verify too 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. --- app/Services/Social/ConnectionVerifier.php | 16 ++++++----- .../Social/ConnectionVerifierTest.php | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index b795eed0..547b78d6 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -47,11 +47,13 @@ public function verify(SocialAccount $account): bool /** * Refresh the token, then verify with the new one. * - * If the refresh is rejected (4xx) but a concurrent refresh has already - * rotated the account and persisted a fresh access_token, reload and - * verify with that token instead of giving up — X (and other providers - * that single-use their refresh_token) otherwise disconnect a still-usable - * account whenever two refreshes race and one loses the rotation. + * If either the refresh is rejected (4xx) or the verify that follows it hits + * a token a concurrent refresh has already rotated — including the sub-commit + * window where a lock-skipped refresh reloads a not-yet-persisted token — + * reload and, when another process has since persisted a fresh access_token, + * verify with that instead of giving up. X (and other providers that + * single-use their refresh_token) would otherwise disconnect a still-usable + * account whenever two refreshes race and one loses. * * @throws TokenExpiredException * @throws PlatformUnavailableException @@ -62,6 +64,8 @@ private function refreshThenVerify(SocialAccount $account, ?TokenExpiredExceptio try { $this->refreshToken($account); + + return $this->callVerifyEndpoint($account); } catch (TokenExpiredException $e) { $account->refresh(); @@ -71,8 +75,6 @@ private function refreshThenVerify(SocialAccount $account, ?TokenExpiredExceptio throw $original ?? $e; } - - return $this->callVerifyEndpoint($account); } /** diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 29a38b99..9d523ec5 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -481,6 +481,33 @@ && $request->header('Authorization')[0] === 'Bearer fresh-token'); }); +test('does not disconnect when the verify after a refresh 401s once but a fresh token is available', function () { + Http::fake([ + // Refresh succeeds and rotates the token... + config('trypost.platforms.x.api').'/oauth2/token' => Http::response([ + 'access_token' => 'refreshed-token', + 'refresh_token' => 'new-refresh', + 'expires_in' => 7200, + ], 200), + // ...but the verify that follows 401s once (the sub-commit window where a + // lock-skipped refresh reloads a not-yet-persisted token) before + // succeeding on the reload + retry. + config('trypost.platforms.x.api').'/users/me' => Http::sequence() + ->push(['error' => 'unauthorized'], 401) + ->push(['data' => ['id' => '123']], 200), + ]); + + $account = SocialAccount::factory()->x()->create([ + 'status' => Status::Connected, + 'access_token' => 'stale-token', + 'refresh_token' => 'old-refresh', + 'token_expires_at' => now()->subHour(), + ]); + + expect((new ConnectionVerifier)->verify($account))->toBeTrue(); + expect($account->fresh()->status)->toBe(Status::Connected); +}); + test('4xx during refresh keeps raising TokenExpiredException', function () { Http::fake([ config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400), From c8f875550a5145023f2074d53e27f254d44b02ce Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 13:11:36 -0300 Subject: [PATCH 11/11] Replace generic token-TTL constant with per-platform Platform::defaultTokenTtlSeconds() 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. --- app/Enums/SocialAccount/Platform.php | 29 ++++++++++++++----- .../Controllers/Auth/InstagramController.php | 2 +- .../Controllers/Auth/ThreadsController.php | 2 +- app/Services/Social/ConnectionVerifier.php | 6 ++-- tests/Unit/Enums/PlatformTest.php | 18 ++++++++++++ 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 808f865b..afcd02f3 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -23,13 +23,6 @@ enum Platform: string case Telegram = 'telegram'; case Discord = 'discord'; - /** - * Fallback lifetime, in seconds, for Meta long-lived tokens (Instagram and - * Threads — see extendsAccessTokenOnRefresh) when the provider response - * omits expires_in. Meta issues these tokens for 60 days. - */ - public const LONG_LIVED_TOKEN_TTL_SECONDS = 5184000; - /** * The social network this platform belongs to. Variants that represent the * same network (LinkedIn profile vs. company page, Instagram standalone vs. @@ -300,6 +293,28 @@ public static function accessTokenExtendingPlatformValues(): array )); } + /** + * The token lifetime, in seconds, to assume when the provider's OAuth + * response omits expires_in. Each value is that network's own documented + * default: + * + * - X: a 2-hour access token. + * - Instagram / Threads: Meta's 60-day long-lived token. + * + * Networks that always return expires_in (LinkedIn, TikTok, YouTube, + * Pinterest), whose refresh sets a fixed lifetime directly (Bluesky), or + * whose tokens never expire (Facebook, Mastodon, Telegram, Discord) have no + * fallback here and return null. + */ + public function defaultTokenTtlSeconds(): ?int + { + return match ($this) { + self::X => 7200, + self::Instagram, self::Threads => 5184000, + default => null, + }; + } + public function queue(): string { return 'social-'.$this->value; diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php index af48218c..f8a0c054 100644 --- a/app/Http/Controllers/Auth/InstagramController.php +++ b/app/Http/Controllers/Auth/InstagramController.php @@ -69,7 +69,7 @@ public function callback(Request $request): InertiaResponse $avatarPath = $socialUser->getAvatar() ? uploadFromUrl($socialUser->getAvatar()) : null; // Calculate token expiration (long-lived tokens last 60 days) - $expiresIn = $socialUser->expiresIn ?? SocialPlatform::LONG_LIVED_TOKEN_TTL_SECONDS; + $expiresIn = $socialUser->expiresIn ?? $this->platform->defaultTokenTtlSeconds(); $tokenExpiresAt = now()->addSeconds($expiresIn); $workspace->socialAccounts()->updateOrCreate( diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index 059b173e..955009ea 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -117,7 +117,7 @@ public function callback(Request $request): InertiaResponse $longLivedData = $longLivedResponse->json(); $longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken; - $expiresIn = $longLivedData['expires_in'] ?? SocialPlatform::LONG_LIVED_TOKEN_TTL_SECONDS; + $expiresIn = $longLivedData['expires_in'] ?? $this->platform->defaultTokenTtlSeconds(); // Fetch user profile $profileResponse = Http::get(config('trypost.platforms.threads.graph_api')."/{$userId}", [ diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 547b78d6..071d554e 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -181,7 +181,7 @@ private function refreshXToken(SocialAccount $account): void $account->update([ 'access_token' => data_get($data, 'access_token'), 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), - 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 7200)), + 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())), ]); $account->refresh(); @@ -328,7 +328,7 @@ private function refreshThreadsToken(SocialAccount $account): void $account->update([ 'access_token' => $newToken, 'refresh_token' => $newToken, - 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', Platform::LONG_LIVED_TOKEN_TTL_SECONDS)), + 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())), ]); $account->refresh(); @@ -350,7 +350,7 @@ private function refreshInstagramToken(SocialAccount $account): void $account->update([ 'access_token' => $newToken, 'refresh_token' => $newToken, - 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', Platform::LONG_LIVED_TOKEN_TTL_SECONDS)), + 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())), ]); $account->refresh(); diff --git a/tests/Unit/Enums/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php index 5c4f94d1..340d302f 100644 --- a/tests/Unit/Enums/PlatformTest.php +++ b/tests/Unit/Enums/PlatformTest.php @@ -85,6 +85,24 @@ expect(Platform::Pinterest->supportsTextOnly())->toBeFalse(); }); +test('platform exposes the correct default token TTL fallback', function () { + // X access tokens live 2 hours. + expect(Platform::X->defaultTokenTtlSeconds())->toBe(7200); + + // Instagram and Threads use Meta's 60-day long-lived token. + expect(Platform::Instagram->defaultTokenTtlSeconds())->toBe(5184000); + expect(Platform::Threads->defaultTokenTtlSeconds())->toBe(5184000); + + // Networks that always return expires_in, set a fixed lifetime directly, or + // never expire have no fallback here. + expect(Platform::LinkedIn->defaultTokenTtlSeconds())->toBeNull(); + expect(Platform::TikTok->defaultTokenTtlSeconds())->toBeNull(); + expect(Platform::YouTube->defaultTokenTtlSeconds())->toBeNull(); + expect(Platform::Pinterest->defaultTokenTtlSeconds())->toBeNull(); + expect(Platform::Bluesky->defaultTokenTtlSeconds())->toBeNull(); + expect(Platform::Facebook->defaultTokenTtlSeconds())->toBeNull(); +}); + test('platform is enabled by default', function () { expect(Platform::LinkedIn->isEnabled())->toBeTrue(); expect(Platform::Instagram->isEnabled())->toBeTrue();