From 49ccfe851e0fdba524441bbba36e984405f237fa Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 14 May 2026 10:55:53 -0300 Subject: [PATCH] fix(linkedin,pinterest): split CSV/space-joined OAuth scopes before saving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LinkedIn and Pinterest's OAuth providers return the granted scope list joined by comma (LinkedIn) or space-in-one-element (Pinterest), but Socialite's scope splitter doesn't match either, so 'approvedScopes' lands as a single-element array containing the whole list: LinkedIn: ['email,openid,profile,r_basicprofile,w_member_social'] Pinterest: ['boards:read boards:write pins:read pins:write user_accounts:read'] That breaks the publish-time scope check in PublishToSocialPlatform (array_diff does exact string compare), surfacing as 'Missing permissions: w_member_social. Please reconnect your account' even though the scopes were actually granted at the provider. Fix is inline at each callback — re-split before saving. Each provider has its own quirk (LinkedIn = comma, Pinterest = space), so each controller handles its own separator. Tests added: callback splits the joined approvedScopes into individual tokens for both providers. --- .../Controllers/Auth/LinkedInController.php | 3 +- .../Controllers/Auth/PinterestController.php | 3 +- .../Feature/Social/LinkedInControllerTest.php | 36 +++++++++++++++++++ .../Social/PinterestControllerTest.php | 28 +++++++++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Auth/LinkedInController.php b/app/Http/Controllers/Auth/LinkedInController.php index d0de89f6..1a6b59d4 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -85,7 +85,8 @@ public function callback(Request $request): View 'access_token' => $socialUser->token, 'refresh_token' => $socialUser->refreshToken, 'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null, - 'scopes' => $socialUser->approvedScopes ?? null, + // LinkedIn returns scope CSV-joined but Socialite splits on space, so re-split here. + 'scopes' => explode(',', implode(',', $socialUser->approvedScopes)), 'status' => Status::Connected, 'error_message' => null, 'disconnected_at' => null, diff --git a/app/Http/Controllers/Auth/PinterestController.php b/app/Http/Controllers/Auth/PinterestController.php index cb3be59a..50748554 100644 --- a/app/Http/Controllers/Auth/PinterestController.php +++ b/app/Http/Controllers/Auth/PinterestController.php @@ -79,7 +79,8 @@ public function callback(Request $request): View 'access_token' => $socialUser->token, 'refresh_token' => $socialUser->refreshToken, 'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : now()->addDays(30), - 'scopes' => $socialUser->approvedScopes ?? $this->scopes, + // Pinterest returns scopes space-joined but Socialite doesn't split them, so re-split here. + 'scopes' => explode(' ', implode(' ', $socialUser->approvedScopes)), 'status' => Status::Connected, ]); diff --git a/tests/Feature/Social/LinkedInControllerTest.php b/tests/Feature/Social/LinkedInControllerTest.php index 398d27bd..c95465a7 100644 --- a/tests/Feature/Social/LinkedInControllerTest.php +++ b/tests/Feature/Social/LinkedInControllerTest.php @@ -84,6 +84,42 @@ ]); }); +test('linkedin oauth callback splits comma-separated approvedScopes before saving', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('abc123xyz'); + $socialiteUser->shouldReceive('getNickname')->andReturn(null); + $socialiteUser->shouldReceive('getName')->andReturn('John Doe'); + $socialiteUser->shouldReceive('getAvatar')->andReturn(null); + $socialiteUser->token = 'test-access-token'; + $socialiteUser->refreshToken = 'test-refresh-token'; + $socialiteUser->expiresIn = 5184000; + // LinkedIn returns scopes comma-separated; Socialite's LinkedInProvider + // splits on space (the OAuth 2.0 default), so approvedScopes lands as + // a single-element array with the whole CSV inside. The save path + // must normalize back to individual tokens. + $socialiteUser->approvedScopes = ['email,openid,profile,r_basicprofile,w_member_social']; + + Socialite::shouldReceive('driver') + ->with('linkedin') + ->andReturn(Mockery::mock(['user' => $socialiteUser])); + + Http::fake([ + 'https://api.linkedin.com/v2/me*' => Http::response([ + 'id' => 'abc123xyz', + 'vanityName' => 'johndoe', + ], 200), + ]); + + $this->actingAs($this->user)->get(route('app.social.linkedin.callback')); + + $account = SocialAccount::where('platform_user_id', 'abc123xyz')->first(); + expect($account->scopes)->toEqualCanonicalizing([ + 'email', 'openid', 'profile', 'r_basicprofile', 'w_member_social', + ]); +}); + test('linkedin callback fails with expired session', function () { // No session data - simulating expired session diff --git a/tests/Feature/Social/PinterestControllerTest.php b/tests/Feature/Social/PinterestControllerTest.php index 6ab5ff0a..4bce71e0 100644 --- a/tests/Feature/Social/PinterestControllerTest.php +++ b/tests/Feature/Social/PinterestControllerTest.php @@ -74,6 +74,34 @@ ]); }); +test('pinterest oauth callback splits space-separated approvedScopes before saving', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('pin-user-xyz'); + $socialiteUser->shouldReceive('getNickname')->andReturn('pinuser'); + $socialiteUser->shouldReceive('getName')->andReturn('Pin User'); + $socialiteUser->shouldReceive('getAvatar')->andReturn(null); + $socialiteUser->token = 'test-access-token'; + $socialiteUser->refreshToken = 'test-refresh-token'; + $socialiteUser->expiresIn = 5184000; + // Pinterest's SocialiteProvider has scopeSeparator = ',' but Pinterest + // returns the granted scopes space-separated, so the provider doesn't + // split them and approvedScopes lands as a single-element array. + $socialiteUser->approvedScopes = ['boards:read boards:write pins:read pins:write user_accounts:read']; + + Socialite::shouldReceive('driver') + ->with('pinterest') + ->andReturn(Mockery::mock(['user' => $socialiteUser])); + + $this->actingAs($this->user)->get(route('app.social.pinterest.callback')); + + $account = SocialAccount::where('platform_user_id', 'pin-user-xyz')->first(); + expect($account->scopes)->toEqualCanonicalizing([ + 'boards:read', 'boards:write', 'pins:read', 'pins:write', 'user_accounts:read', + ]); +}); + test('pinterest callback fails with expired session', function () { // No session data - simulating expired session