From 595510812cd097c350729239079da523feff4588 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 3 May 2026 21:58:25 -0300 Subject: [PATCH] refactor: implement lazy token refreshing and persist Mastodon scopes --- .../Controllers/Auth/MastodonController.php | 7 ++ app/Models/SocialAccount.php | 2 +- app/Services/Social/ConnectionVerifier.php | 40 ++++++++- .../Social/ConnectionVerifierTest.php | 86 +++++++++++++++++-- .../Feature/Social/MastodonControllerTest.php | 3 + 5 files changed, 128 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Auth/MastodonController.php b/app/Http/Controllers/Auth/MastodonController.php index 67edaffe..630286fe 100644 --- a/app/Http/Controllers/Auth/MastodonController.php +++ b/app/Http/Controllers/Auth/MastodonController.php @@ -183,6 +183,12 @@ public function callback(Request $request): View $avatarPath = data_get($profile, 'avatar') ? uploadFromUrl(data_get($profile, 'avatar')) : null; + // Mastodon returns the granted scopes in the token response as a + // space-separated string. We persist them so the publisher can + // verify required scopes (write:statuses, write:media) before + // attempting to post. + $grantedScopes = array_values(array_filter(explode(' ', (string) data_get($tokenData, 'scope', self::SCOPES)))); + $workspace->socialAccounts()->updateOrCreate( [ 'platform' => $this->platform->value, @@ -195,6 +201,7 @@ public function callback(Request $request): View 'access_token' => $accessToken, 'refresh_token' => null, 'token_expires_at' => null, + 'scopes' => $grantedScopes, 'status' => Status::Connected, 'error_message' => null, 'disconnected_at' => null, diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 8ef09c4f..afa6899d 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -86,7 +86,7 @@ protected function isTokenExpired(): Attribute protected function isTokenExpiringSoon(): Attribute { return Attribute::make( - get: fn () => $this->token_expires_at && $this->token_expires_at->isBefore(now()->addHour()), + get: fn () => $this->token_expires_at && $this->token_expires_at->isBefore(now()->addMinutes(15)), ); } diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 872ef335..742933ca 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -20,11 +20,39 @@ class ConnectionVerifier */ public function verify(SocialAccount $account): bool { - // Refresh token if expired or expiring soon before verifying - if ($account->is_token_expired || $account->is_token_expiring_soon) { + // Hard-expired tokens cannot make API calls — refresh is mandatory. + // For tokens that are still valid OR only "expiring soon", try the + // verify endpoint FIRST with the current access_token. This avoids + // rotating refresh_tokens unnecessarily — many providers (X v2, + // 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. + if ($account->is_token_expired) { $this->refreshTokenIfNeeded($account); + + return $this->callVerifyEndpoint($account); } + try { + return $this->callVerifyEndpoint($account); + } catch (TokenExpiredException $e) { + // Verify returned 401: the access_token is actually invalid. + // Refresh and retry once with the new token. + try { + $this->refreshTokenIfNeeded($account); + } catch (TokenExpiredException) { + throw $e; + } + + return $this->callVerifyEndpoint($account); + } + } + + /** + * @throws TokenExpiredException + */ + private function callVerifyEndpoint(SocialAccount $account): bool + { return match ($account->platform) { Platform::LinkedIn => $this->verifyLinkedIn($account), Platform::LinkedInPage => $this->verifyLinkedInPage($account), @@ -377,7 +405,13 @@ private function verifyX(SocialAccount $account): bool private function verifyInstagram(SocialAccount $account): bool { - $response = Http::get(config('trypost.platforms.instagram.graph_api').'/me', [ + // Basic Instagram tokens hit graph.instagram.com; Instagram via + // Facebook Business uses a Facebook Page token, which only validates + // against graph.facebook.com — using the wrong endpoint produces a + // false-positive "token expired". + $baseUrl = $account->platform->instagramGraphBaseUrl(); + + $response = Http::get("{$baseUrl}/me", [ 'fields' => 'id,username', 'access_token' => $account->access_token, ]); diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 7c83ebf1..cbadf68d 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -263,7 +263,68 @@ Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token')); }); -test('refreshes token when expiring soon', function () { +test('does NOT refresh proactively when token still works (lazy refresh)', function () { + Http::fake([ + 'api.linkedin.com/*' => Http::response(['sub' => '123'], 200), + ]); + + // Token is "expiring soon" but access_token still works. + $account = SocialAccount::factory()->linkedin()->create([ + 'token_expires_at' => now()->addMinutes(10), + 'refresh_token' => 'old_refresh_token', + ]); + + $verifier = new ConnectionVerifier; + + expect($verifier->verify($account))->toBeTrue(); + + // Refresh endpoint must NOT have been called — verify worked without it. + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken')); + Http::assertSent(fn ($request) => str_contains($request->url(), 'api.linkedin.com/rest/userinfo')); +}); + +test('refreshes lazily on 401 then retries verify', function () { + Http::fake([ + // First verify call returns 401, second (after refresh) returns 200. + 'api.linkedin.com/*' => Http::sequence() + ->push(['error' => 'unauthorized'], 401) + ->push(['sub' => '123'], 200), + 'www.linkedin.com/oauth/v2/accessToken' => Http::response([ + 'access_token' => 'new_token', + 'refresh_token' => 'new_refresh_token', + 'expires_in' => 5184000, + ], 200), + ]); + + $account = SocialAccount::factory()->linkedin()->create([ + 'token_expires_at' => now()->addHours(2), + 'refresh_token' => 'old_refresh_token', + ]); + + $verifier = new ConnectionVerifier; + + expect($verifier->verify($account))->toBeTrue(); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken')); +}); + +test('throws when verify returns 401 AND refresh also fails', function () { + Http::fake([ + 'api.linkedin.com/*' => Http::response(['error' => 'unauthorized'], 401), + 'www.linkedin.com/oauth/v2/accessToken' => Http::response(['error' => 'invalid_grant'], 400), + ]); + + $account = SocialAccount::factory()->linkedin()->create([ + 'token_expires_at' => now()->addHours(2), + 'refresh_token' => 'old_refresh_token', + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->verify($account))->toThrow(TokenExpiredException::class); +}); + +test('forces refresh when token is hard-expired', function () { Http::fake([ 'www.linkedin.com/oauth/v2/accessToken' => Http::response([ 'access_token' => 'new_token', @@ -273,16 +334,29 @@ 'api.linkedin.com/*' => Http::response(['sub' => '123'], 200), ]); - // Token expires in 30 minutes (less than 1 hour threshold) $account = SocialAccount::factory()->linkedin()->create([ - 'token_expires_at' => now()->addMinutes(30), + 'token_expires_at' => now()->subMinutes(5), 'refresh_token' => 'old_refresh_token', ]); $verifier = new ConnectionVerifier; - $result = $verifier->verify($account); - expect($result)->toBeTrue(); + expect($verifier->verify($account))->toBeTrue(); - Http::assertSent(fn ($request) => str_contains($request->url(), 'linkedin.com/oauth/v2/accessToken')); + Http::assertSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken')); +}); + +test('throws when refresh fails AND token is hard-expired', function () { + Http::fake([ + 'www.linkedin.com/oauth/v2/accessToken' => Http::response(['error' => 'invalid_grant'], 400), + ]); + + $account = SocialAccount::factory()->linkedin()->create([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => 'old_refresh_token', + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->verify($account))->toThrow(TokenExpiredException::class); }); diff --git a/tests/Feature/Social/MastodonControllerTest.php b/tests/Feature/Social/MastodonControllerTest.php index ee7c6ce3..cab9483d 100644 --- a/tests/Feature/Social/MastodonControllerTest.php +++ b/tests/Feature/Social/MastodonControllerTest.php @@ -102,6 +102,9 @@ 'username' => 'testuser', 'status' => Status::Connected->value, ]); + + $account = SocialAccount::where('platform', Platform::Mastodon->value)->first(); + expect($account->scopes)->toBe(['read:accounts', 'write:statuses', 'write:media']); }); test('mastodon callback fails with invalid state', function () {