diff --git a/.env.example b/.env.example index 6d4c3104..fc3ed8b5 100644 --- a/.env.example +++ b/.env.example @@ -102,7 +102,10 @@ SPACES_BUCKET= LINKEDIN_CLIENT_ID= LINKEDIN_CLIENT_SECRET= LINKEDIN_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin/callback" +# LINKEDIN_SCOPES="openid,profile,email,w_member_social" LINKEDIN_PAGE_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin-page/callback" +LINKEDIN_PAGE_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin-page/callback" +# LINKEDIN_PAGE_SCOPES="openid,profile,email,w_organization_social,r_organization_social,rw_organization_admin,w_member_social" # X / Twitter (https://developer.twitter.com) X_CLIENT_ID= diff --git a/app/Http/Controllers/Auth/LinkedInController.php b/app/Http/Controllers/Auth/LinkedInController.php index 4dd59e4e..a493d725 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -22,14 +22,6 @@ class LinkedInController extends SocialController protected SocialPlatform $platform = SocialPlatform::LinkedIn; - protected array $scopes = [ - 'openid', - 'profile', - 'email', - 'r_basicprofile', - 'w_member_social', - ]; - public function connect(Request $request): Response|RedirectResponse { $this->ensurePlatformEnabled(); @@ -42,7 +34,7 @@ public function connect(Request $request): Response|RedirectResponse $this->authorize('manageAccounts', $workspace); - return $this->redirectToProvider($request, $this->driver, $this->scopes); + return $this->redirectToProvider($request, $this->driver, config('trypost.platforms.linkedin.scopes')); } public function callback(Request $request): View diff --git a/app/Http/Controllers/Auth/LinkedInPageController.php b/app/Http/Controllers/Auth/LinkedInPageController.php index 03d58096..c60c6815 100644 --- a/app/Http/Controllers/Auth/LinkedInPageController.php +++ b/app/Http/Controllers/Auth/LinkedInPageController.php @@ -24,16 +24,6 @@ class LinkedInPageController extends SocialController protected SocialPlatform $platform = SocialPlatform::LinkedInPage; - protected array $scopes = [ - 'openid', - 'profile', - 'email', - 'w_organization_social', - 'r_organization_social', - 'rw_organization_admin', - 'w_member_social', - ]; - public function connect(Request $request): SymfonyResponse|RedirectResponse { $this->ensurePlatformEnabled(); @@ -55,7 +45,7 @@ public function connect(Request $request): SymfonyResponse|RedirectResponse return Inertia::location( Socialite::driver($this->driver) - ->scopes($this->scopes) + ->scopes(config('trypost.platforms.linkedin-page.scopes')) ->with([ 'redirect_uri' => config('services.linkedin-openid.redirect_page'), ]) @@ -80,7 +70,7 @@ public function callback(Request $request): View|RedirectResponse try { $socialUser = Socialite::driver($this->driver) - ->scopes($this->scopes) + ->scopes(config('trypost.platforms.linkedin-page.scopes')) ->with([ 'redirect_uri' => config('services.linkedin-openid.redirect_page'), ]) diff --git a/config/trypost.php b/config/trypost.php index 0b1788ae..a541cd47 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -80,10 +80,14 @@ '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'), + // Scopes for LinkedIn authentication + 'scopes' => array_values(array_filter(array_map('trim', explode(',', (string) env('LINKEDIN_SCOPES', 'openid,profile,email,w_member_social'))))), ], 'linkedin-page' => [ 'enabled' => env('LINKEDIN_PAGE_ENABLED', true), 'api' => env('LINKEDIN_PAGE_API', 'https://api.linkedin.com'), + // Scopes for LinkedIn Page authentication + 'scopes' => array_values(array_filter(array_map('trim', explode(',', (string) env('LINKEDIN_PAGE_SCOPES', 'openid,profile,email,w_organization_social,r_organization_social,rw_organization_admin,w_member_social'))))), ], 'x' => [ 'enabled' => env('X_ENABLED', true), diff --git a/docker/.env.docker.example b/docker/.env.docker.example index aaacd5fd..5d8e48b6 100644 --- a/docker/.env.docker.example +++ b/docker/.env.docker.example @@ -89,7 +89,9 @@ R2_URL= LINKEDIN_CLIENT_ID= LINKEDIN_CLIENT_SECRET= LINKEDIN_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin/callback" +# LINKEDIN_SCOPES="openid,profile,email,w_member_social" LINKEDIN_PAGE_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin-page/callback" +# LINKEDIN_PAGE_SCOPES="openid,profile,email,w_organization_social,r_organization_social,rw_organization_admin,w_member_social" X_CLIENT_ID= X_CLIENT_SECRET= diff --git a/tests/Feature/Social/LinkedInControllerTest.php b/tests/Feature/Social/LinkedInControllerTest.php index c95465a7..02ce1a50 100644 --- a/tests/Feature/Social/LinkedInControllerTest.php +++ b/tests/Feature/Social/LinkedInControllerTest.php @@ -39,6 +39,57 @@ expect(session('social_connect_workspace'))->toBe($this->workspace->id); }); +/** + * Mock the LinkedIn Socialite driver, hit /connect, and return the scopes + * the controller requested. + * + * @return array + */ +$captureConnectScopes = function (object $test): array { + $captured = []; + + $driverMock = Mockery::mock(); + $driverMock->shouldReceive('scopes') + ->withArgs(function (array $scopes) use (&$captured) { + $captured = $scopes; + + return true; + }) + ->andReturnSelf(); + $driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([ + 'getTargetUrl' => 'https://www.linkedin.com/oauth/v2/authorization?test=1', + ])); + + Socialite::shouldReceive('driver') + ->with('linkedin') + ->andReturn($driverMock); + + $test->actingAs($test->user) + ->withHeader('X-Inertia', 'true') + ->get(route('app.social.linkedin.connect')); + + return $captured; +}; + +test('linkedin connect requests the default scope set', function () use ($captureConnectScopes) { + config(['trypost.platforms.linkedin.scopes' => ['openid', 'profile', 'email', 'w_member_social']]); + + expect($captureConnectScopes($this))->toEqualCanonicalizing([ + 'openid', 'profile', 'email', 'w_member_social', + ]); +}); + +test('linkedin connect requests the scopes configured via LINKEDIN_SCOPES', function () use ($captureConnectScopes) { + // Operators whose LinkedIn app has legacy/enterprise products approved + // override the default set via LINKEDIN_SCOPES (exploded into the config + // array), e.g. re-adding r_basicprofile to restore the vanityName lookup. + config(['trypost.platforms.linkedin.scopes' => ['openid', 'profile', 'email', 'w_member_social', 'r_basicprofile']]); + + expect($captureConnectScopes($this))->toEqualCanonicalizing([ + 'openid', 'profile', 'email', 'w_member_social', 'r_basicprofile', + ]); +}); + test('linkedin oauth callback creates account', function () { session([ 'social_connect_workspace' => $this->workspace->id, @@ -99,7 +150,7 @@ // 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']; + $socialiteUser->approvedScopes = ['email,openid,profile,w_member_social']; Socialite::shouldReceive('driver') ->with('linkedin') @@ -116,7 +167,7 @@ $account = SocialAccount::where('platform_user_id', 'abc123xyz')->first(); expect($account->scopes)->toEqualCanonicalizing([ - 'email', 'openid', 'profile', 'r_basicprofile', 'w_member_social', + 'email', 'openid', 'profile', 'w_member_social', ]); });