From 6f96d67dbcc089a27918a0f7102ffb160b94c0d0 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 08:16:43 -0300 Subject: [PATCH] fix(social): distinguish platform-down from token-expired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a provider's API was down (5xx, timeout, DNS), the hourly RefreshSocialToken job and daily VerifyWorkspaceConnections job were treating it as "token revoked" and emailing the user to reconnect. Bluesky going offline triggered false-positive disconnect notifications because Bluesky access tokens are short-lived (2h) so every hourly refresh failed during the outage. - New PlatformUnavailableException: API unreachable / 5xx, transient. TokenExpiredException stays for 4xx (token is provably bad). - New TokenRefreshClient: normalizes failure semantics for OAuth refresh HTTP calls across all providers. Takes a Platform enum so typos fail at compile time and the user-facing label comes from one source. - ConnectionVerifier: all 8 refresh*Token methods route through the new client. Hardcoded OAuth URLs (LinkedIn, YouTube) and Bluesky's default PDS host moved into config/trypost.php alongside the existing per-platform entries. - RefreshSocialToken job: PlatformUnavailableException → log warning and stop. Do NOT markAsTokenExpired, do NOT notify the user. Next scheduled tick retries. - VerifyWorkspaceConnections job: PlatformUnavailableException from the inner refresh propagates and is treated as a transient skip. --- .../PlatformUnavailableException.php | 23 +++ app/Jobs/RefreshSocialToken.php | 11 ++ app/Jobs/VerifyWorkspaceConnections.php | 11 ++ app/Services/Social/ConnectionVerifier.php | 166 ++++++------------ app/Services/Social/TokenRefreshClient.php | 87 +++++++++ config/trypost.php | 5 + tests/Feature/Jobs/RefreshSocialTokenTest.php | 23 +++ .../Social/ConnectionVerifierTest.php | 70 ++++++++ .../VerifyWorkspaceConnectionsTest.php | 21 +++ 9 files changed, 309 insertions(+), 108 deletions(-) create mode 100644 app/Exceptions/PlatformUnavailableException.php create mode 100644 app/Services/Social/TokenRefreshClient.php diff --git a/app/Exceptions/PlatformUnavailableException.php b/app/Exceptions/PlatformUnavailableException.php new file mode 100644 index 00000000..27c10e4b --- /dev/null +++ b/app/Exceptions/PlatformUnavailableException.php @@ -0,0 +1,23 @@ +refreshToken($this->account); + } catch (PlatformUnavailableException $e) { + // Platform is down (5xx / network). Leave the account alone — + // next scheduled run will try again. Critically, do NOT mark + // the account expired: that would trigger a false-positive + // "reconnect your account" notification. + Log::warning('Token refresh skipped: platform unavailable', [ + 'account_id' => $this->account->id, + 'platform' => $this->account->platform->value, + 'error' => $e->getMessage(), + ]); } catch (TokenExpiredException $e) { // refresh_token rejected by the provider (revoked / rotated / // expired beyond refresh). Mark the account so the user is diff --git a/app/Jobs/VerifyWorkspaceConnections.php b/app/Jobs/VerifyWorkspaceConnections.php index d635ed97..5174b13d 100644 --- a/app/Jobs/VerifyWorkspaceConnections.php +++ b/app/Jobs/VerifyWorkspaceConnections.php @@ -7,6 +7,7 @@ use App\Enums\Notification\Channel; use App\Enums\Notification\Type; use App\Enums\SocialAccount\Status; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Mail\WorkspaceConnectionsDisconnected; use App\Models\SocialAccount; @@ -63,6 +64,16 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco try { $verifier->verify($account); + return true; + } catch (PlatformUnavailableException $e) { + // Platform is down (5xx / network). The account's token is not + // provably bad — skip silently and try again next pass. + Log::warning('Social account verification skipped: platform unavailable', [ + 'account_id' => $account->id, + 'platform' => $account->platform->value, + 'error' => $e->getMessage(), + ]); + return true; } catch (TokenExpiredException $e) { Log::warning('Social account connection is invalid', [ diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index d65a493d..e5c9fc17 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -5,11 +5,11 @@ namespace App\Services\Social; use App\Enums\SocialAccount\Platform; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Log; class ConnectionVerifier { @@ -17,6 +17,7 @@ class ConnectionVerifier * Verify that a social account connection is still valid. * * @throws TokenExpiredException if the connection is invalid + * @throws PlatformUnavailableException if the platform's API is down */ public function verify(SocialAccount $account): bool { @@ -27,6 +28,9 @@ public function verify(SocialAccount $account): bool // LinkedIn, etc.) invalidate the previous refresh_token on each // refresh, so proactive refreshes during races cause false-positive // disconnects even though the access_token still works fine. + // + // PlatformUnavailableException from refresh propagates naturally so + // the caller can distinguish "platform is down" from "token bad". if ($account->is_token_expired) { $this->refreshToken($account); @@ -113,17 +117,13 @@ private function refreshLinkedInToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for LinkedIn account'); } - $response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.linkedin.client_id'), - 'client_secret' => config('services.linkedin.client_secret'), - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: LinkedIn token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh LinkedIn token'); - } + $response = TokenRefreshClient::for(Platform::LinkedIn)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.linkedin.client_id'), + 'client_secret' => config('services.linkedin.client_secret'), + ])); $data = $response->json(); @@ -145,17 +145,12 @@ private function refreshXToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for X account'); } - $response = Http::asForm() + $response = TokenRefreshClient::for(Platform::X)->send(fn () => Http::asForm() ->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret')) ->post(config('trypost.platforms.x.api').'/oauth2/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: X token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh X token'); - } + ])); $data = $response->json(); @@ -170,13 +165,16 @@ private function refreshXToken(SocialAccount $account): void private function refreshBlueskyToken(SocialAccount $account): void { - $service = $account->meta['service'] ?? 'https://bsky.social'; + $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); + $client = TokenRefreshClient::for(Platform::Bluesky); - // Try refresh token first - $response = Http::withToken($account->refresh_token) - ->post("{$service}/xrpc/com.atproto.server.refreshSession"); + // Try refresh token first. Connection errors / 5xx surface as + // PlatformUnavailableException (Bluesky is down — don't touch the + // account's status). 4xx falls through to the re-auth fallback. + try { + $response = $client->send(fn () => Http::withToken($account->refresh_token) + ->post("{$service}/xrpc/com.atproto.server.refreshSession")); - if ($response->successful()) { $data = $response->json(); $account->update([ 'access_token' => data_get($data, 'accessJwt'), @@ -187,35 +185,29 @@ private function refreshBlueskyToken(SocialAccount $account): void $account->refresh(); return; + } catch (TokenExpiredException) { + // refresh token was rejected (4xx) — fall back to re-auth below } - // If refresh fails, re-authenticate with stored credentials if (isset($account->meta['password'])) { try { - $password = decrypt($account->meta['password']); - $identifier = $account->meta['identifier']; + $reauth = $client->send(fn () => Http::post("{$service}/xrpc/com.atproto.server.createSession", [ + 'identifier' => $account->meta['identifier'], + 'password' => decrypt($account->meta['password']), + ])); - $response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [ - 'identifier' => $identifier, - 'password' => $password, + $data = $reauth->json(); + $account->update([ + 'access_token' => data_get($data, 'accessJwt'), + 'refresh_token' => data_get($data, 'refreshJwt'), + 'token_expires_at' => now()->addHours(2), ]); - if ($response->successful()) { - $data = $response->json(); - $account->update([ - 'access_token' => data_get($data, 'accessJwt'), - 'refresh_token' => data_get($data, 'refreshJwt'), - 'token_expires_at' => now()->addHours(2), - ]); + $account->refresh(); - $account->refresh(); - - return; - } - } catch (\Exception $e) { - Log::error('ConnectionVerifier: Bluesky re-authentication failed', [ - 'error' => $e->getMessage(), - ]); + return; + } catch (TokenExpiredException) { + // re-auth rejected with stored credentials — fall through } } @@ -228,17 +220,13 @@ private function refreshYouTubeToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for YouTube account'); } - $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.google.client_id'), - 'client_secret' => config('services.google.client_secret'), - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: YouTube token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh YouTube token'); - } + $response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.youtube.oauth_api').'/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.google.client_id'), + 'client_secret' => config('services.google.client_secret'), + ])); $data = $response->json(); @@ -256,17 +244,13 @@ private function refreshTikTokToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for TikTok account'); } - $response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_key' => config('services.tiktok.client_id'), - 'client_secret' => config('services.tiktok.client_secret'), - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: TikTok token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh TikTok token'); - } + $response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_key' => config('services.tiktok.client_id'), + 'client_secret' => config('services.tiktok.client_secret'), + ])); $data = $response->json(); @@ -287,18 +271,13 @@ private function refreshPinterestToken(SocialAccount $account): void $credentials = base64_encode(config('services.pinterest.client_id').':'.config('services.pinterest.client_secret')); - $response = Http::withHeaders([ + $response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::withHeaders([ 'Authorization' => "Basic {$credentials}", 'Content-Type' => 'application/x-www-form-urlencoded', ])->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: Pinterest token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh Pinterest token'); - } + ])); $data = $response->json(); @@ -314,15 +293,10 @@ private function refreshPinterestToken(SocialAccount $account): void private function refreshThreadsToken(SocialAccount $account): void { // Threads uses long-lived tokens that can be refreshed - $response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_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, - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: Threads token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh Threads token'); - } + ])); $data = $response->json(); $newToken = data_get($data, 'access_token'); @@ -338,15 +312,10 @@ private function refreshThreadsToken(SocialAccount $account): void private function refreshInstagramToken(SocialAccount $account): void { - $response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_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, - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: Instagram token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh Instagram token'); - } + ])); $data = $response->json(); $newToken = data_get($data, 'access_token'); @@ -524,7 +493,7 @@ private function verifyPinterest(SocialAccount $account): bool private function verifyBluesky(SocialAccount $account): bool { - $service = $account->meta['service'] ?? 'https://bsky.social'; + $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); $response = Http::withToken($account->access_token) ->get("{$service}/xrpc/app.bsky.actor.getProfile", [ @@ -554,23 +523,4 @@ private function verifyMastodon(SocialAccount $account): bool return $response->successful(); } - - private function redactBody(string $body): string - { - return preg_replace( - [ - '/access_token=([^&"\s]+)/', - '/"access_token"\s*:\s*"([^"]+)"/', - '/Bearer\s+\S+/', - '/"token"\s*:\s*"([^"]+)"/', - ], - [ - 'access_token=[REDACTED]', - '"access_token":"[REDACTED]"', - 'Bearer [REDACTED]', - '"token":"[REDACTED]"', - ], - $body - ); - } } diff --git a/app/Services/Social/TokenRefreshClient.php b/app/Services/Social/TokenRefreshClient.php new file mode 100644 index 00000000..e1959897 --- /dev/null +++ b/app/Services/Social/TokenRefreshClient.php @@ -0,0 +1,87 @@ +platform->label(); + + try { + $response = $request(); + } catch (ConnectionException $e) { + throw new PlatformUnavailableException("{$name} API unreachable: {$e->getMessage()}"); + } + + if ($response->serverError()) { + throw new PlatformUnavailableException( + "{$name} API returned {$response->status()} during token refresh", + $response->status(), + ); + } + + if ($response->failed()) { + Log::error("TokenRefreshClient: {$name} token refresh failed", [ + 'body' => $this->redactBody($response->body()), + ]); + throw new TokenExpiredException("Failed to refresh {$name} token"); + } + + return $response; + } + + private function redactBody(string $body): string + { + return preg_replace( + [ + '/access_token=([^&"\s]+)/', + '/"access_token"\s*:\s*"([^"]+)"/', + '/Bearer\s+\S+/', + '/"token"\s*:\s*"([^"]+)"/', + ], + [ + 'access_token=[REDACTED]', + '"access_token":"[REDACTED]"', + 'Bearer [REDACTED]', + '"token":"[REDACTED]"', + ], + $body + ); + } +} diff --git a/config/trypost.php b/config/trypost.php index 9ab2e2b9..29fcfb95 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -63,6 +63,8 @@ 'linkedin' => [ 'enabled' => env('LINKEDIN_ENABLED', true), 'api' => env('LINKEDIN_API', 'https://api.linkedin.com'), + // OAuth host is different from the data API (api.linkedin.com). + 'oauth_api' => env('LINKEDIN_OAUTH_API', 'https://www.linkedin.com'), ], 'linkedin-page' => [ 'enabled' => env('LINKEDIN_PAGE_ENABLED', true), @@ -80,6 +82,7 @@ 'enabled' => env('YOUTUBE_ENABLED', true), 'data_api' => env('YOUTUBE_DATA_API', 'https://www.googleapis.com/youtube/v3'), 'analytics_api' => env('YOUTUBE_ANALYTICS_API', 'https://youtubeanalytics.googleapis.com/v2'), + 'oauth_api' => env('YOUTUBE_OAUTH_API', 'https://oauth2.googleapis.com'), ], 'facebook' => [ 'enabled' => env('FACEBOOK_ENABLED', true), @@ -108,6 +111,8 @@ 'bluesky' => [ 'enabled' => env('BLUESKY_ENABLED', true), 'public_appview' => env('BLUESKY_PUBLIC_APPVIEW', 'https://public.api.bsky.app'), + // Default PDS used when the account has no `meta.service` override. + 'default_service' => env('BLUESKY_DEFAULT_SERVICE', 'https://bsky.social'), ], 'mastodon' => [ 'enabled' => env('MASTODON_ENABLED', true), diff --git a/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index 41b956c1..172076a7 100644 --- a/tests/Feature/Jobs/RefreshSocialTokenTest.php +++ b/tests/Feature/Jobs/RefreshSocialTokenTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\SocialAccount\Status; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Jobs\RefreshSocialToken; use App\Jobs\SendNotification; @@ -67,3 +68,25 @@ expect($this->account->fresh()->status)->toBe(Status::Connected); }); + +test('refresh job does NOT mark account expired when platform is unavailable', function () { + Queue::fake(); + + Log::shouldReceive('warning')->once()->withArgs(function ($message, $context) { + return $message === 'Token refresh skipped: platform unavailable' + && $context['account_id'] === $this->account->id + && str_contains($context['error'], '503'); + }); + + $verifier = mock(ConnectionVerifier::class); + $verifier->shouldReceive('refreshToken')->once()->andThrow( + new PlatformUnavailableException('X API returned 503 during token refresh', 503) + ); + app()->instance(ConnectionVerifier::class, $verifier); + + (new RefreshSocialToken($this->account))->handle($verifier); + + // Critically: account status stays Connected, no notification dispatched. + expect($this->account->fresh()->status)->toBe(Status::Connected); + Queue::assertNotPushed(SendNotification::class); +}); diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index cbadf68d..9d025385 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -2,9 +2,11 @@ declare(strict_types=1); +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use App\Services\Social\ConnectionVerifier; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; test('verifies account without refresh when token is not expired', function () { @@ -360,3 +362,71 @@ expect(fn () => $verifier->verify($account))->toThrow(TokenExpiredException::class); }); + +test('5xx during refresh raises PlatformUnavailableException, not TokenExpiredException', function () { + Http::fake([ + 'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response('upstream timeout', 503), + ]); + + $account = SocialAccount::factory()->bluesky()->create([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => 'old_refresh_token', + 'meta' => ['service' => 'https://bsky.social'], + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); +}); + +test('connection failure during refresh raises PlatformUnavailableException', function () { + Http::fake([ + 'oauth2.googleapis.com/token' => fn () => throw new ConnectionException('cURL error 7: connection refused'), + ]); + + $account = SocialAccount::factory()->youtube()->create([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => 'old_refresh_token', + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); +}); + +test('4xx during refresh keeps raising TokenExpiredException', function () { + Http::fake([ + 'api.x.com/2/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400), + ]); + + $account = SocialAccount::factory()->x()->create([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => 'old_refresh_token', + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->refreshToken($account))->toThrow(TokenExpiredException::class); +}); + +test('bluesky 5xx during refresh raises PlatformUnavailable even when password fallback is stored', function () { + Http::fake([ + // Both endpoints return 5xx — the platform is genuinely down. + 'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response('upstream timeout', 503), + 'bsky.social/xrpc/com.atproto.server.createSession' => Http::response('upstream timeout', 503), + ]); + + $account = SocialAccount::factory()->bluesky()->create([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => 'old_refresh_token', + 'meta' => [ + 'service' => 'https://bsky.social', + 'identifier' => 'user.bsky.social', + 'password' => encrypt('app-password'), + ], + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); +}); diff --git a/tests/Feature/VerifyWorkspaceConnectionsTest.php b/tests/Feature/VerifyWorkspaceConnectionsTest.php index 2f351c78..5eaff679 100644 --- a/tests/Feature/VerifyWorkspaceConnectionsTest.php +++ b/tests/Feature/VerifyWorkspaceConnectionsTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\SocialAccount\Status; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Jobs\VerifyWorkspaceConnections; use App\Mail\WorkspaceConnectionsDisconnected; @@ -121,6 +122,26 @@ }); }); +test('job does NOT disconnect or email when platform is unavailable', function () { + Mail::fake(); + + $workspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->bluesky()->create(['workspace_id' => $workspace->id]); + + $verifier = mock(ConnectionVerifier::class); + $verifier->shouldReceive('verify')->andThrow( + new PlatformUnavailableException('Bluesky API returned 503 during token refresh', 503) + ); + + app()->instance(ConnectionVerifier::class, $verifier); + + VerifyWorkspaceConnections::dispatch($workspace); + + // Status untouched, no notification email. + expect($account->fresh()->status)->toBe(Status::Connected); + Mail::assertNothingQueued(); +}); + test('job skips already disconnected accounts', function () { Mail::fake();