From 410eb9612ef3e48966e244247a16fdcddce2dede Mon Sep 17 00:00:00 2001 From: Falconiere Barbosa Date: Thu, 28 May 2026 00:54:26 -0300 Subject: [PATCH 1/5] fix(linkedin): drop deprecated r_basicprofile from default scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make r_basicprofile opt-in via LINKEDIN_EXTRA_SCOPES so self-hosted users unblock by default and ops with legacy/enterprise products keep working. Why --- LinkedIn rejects OAuth authorize requests with a generic "Bummer, something went wrong" page when an app asks for a scope it can't grant. `r_basicprofile` is a legacy scope deprecated in 2018; new LinkedIn dev apps don't have it, so every self-hosted user hits the rejection immediately on `/connect/linkedin`. The two products LinkedIn actually grants to standard apps today are: - Sign In with LinkedIn using OpenID Connect → `openid profile email` - Share on LinkedIn → `w_member_social` That set is enough for the connect flow. The only piece of data `r_basicprofile` was buying us is `/v2/me`'s `vanityName` (pretty `linkedin.com/in/`). `fetchVanityName()` already handles HTTP failure gracefully (returns null), and the only downstream consumer — `LinkedInPagePublisher`'s post-URL builder — already falls back to a numeric `linkedin.com/feed/update/` URL when `$account->username` is null. Backward compatibility ---------------------- Ops with legacy or enterprise LinkedIn products approved on their dev app (so they DO have `r_basicprofile`) can opt back in via env: LINKEDIN_EXTRA_SCOPES=r_basicprofile `LinkedInController::resolveScopes()` merges this comma-separated list into the default scope array. The connect flow's `Socialite::scopes()` call then includes the legacy scope, preserving the pre-PR behaviour end-to-end (including `vanityName` lookup). Net effect for users without `r_basicprofile`: - Connect flow works (was previously rejected by LinkedIn). - Posts publish exactly the same way. - Generated post URLs use the numeric form instead of the vanity slug. Tests ----- - `linkedin connect requests the default scope set when LINKEDIN_EXTRA_SCOPES is unset` - `linkedin connect appends LINKEDIN_EXTRA_SCOPES to the default scope set` - Existing `splits comma-separated approvedScopes` fixture updated to match the new default set. --- .env.example | 5 ++ .../Controllers/Auth/LinkedInController.php | 22 +++++- config/services.php | 6 ++ docker/.env.docker.example | 5 ++ .../Feature/Social/LinkedInControllerTest.php | 67 ++++++++++++++++++- 5 files changed, 101 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 6d4c3104..3ac86820 100644 --- a/.env.example +++ b/.env.example @@ -103,6 +103,11 @@ LINKEDIN_CLIENT_ID= LINKEDIN_CLIENT_SECRET= LINKEDIN_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin/callback" LINKEDIN_PAGE_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin-page/callback" +# Optional: comma-separated extra OAuth scopes appended to the personal +# LinkedIn connect flow. Use this if your LinkedIn dev app has legacy or +# enterprise products approved — e.g. `r_basicprofile` re-enables +# vanityName lookup via /v2/me. Leave empty for the safe default set. +# LINKEDIN_EXTRA_SCOPES=r_basicprofile # 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..c619d210 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -26,7 +26,6 @@ class LinkedInController extends SocialController 'openid', 'profile', 'email', - 'r_basicprofile', 'w_member_social', ]; @@ -42,7 +41,26 @@ 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, $this->resolveScopes()); + } + + /** + * Merge $this->scopes with any extra scopes the operator configured via + * `LINKEDIN_EXTRA_SCOPES` — comma-separated, e.g. `r_basicprofile`. Useful + * when the connected LinkedIn dev app has legacy or enterprise products + * not covered by the default Sign-In + Share-on-LinkedIn pair. + */ + protected function resolveScopes(): array + { + $extra = (string) config('services.linkedin.extra_scopes', ''); + + if ($extra === '') { + return $this->scopes; + } + + $extraScopes = array_filter(array_map('trim', explode(',', $extra))); + + return array_values(array_unique([...$this->scopes, ...$extraScopes])); } public function callback(Request $request): View diff --git a/config/services.php b/config/services.php index b6c194cc..f920d9ed 100644 --- a/config/services.php +++ b/config/services.php @@ -41,6 +41,12 @@ 'client_id' => env('LINKEDIN_CLIENT_ID'), 'client_secret' => env('LINKEDIN_CLIENT_SECRET'), 'redirect' => env('LINKEDIN_CLIENT_REDIRECT'), + // Comma-separated list of OAuth scopes to request beyond the defaults + // (openid, profile, email, w_member_social). Useful for apps with + // legacy or enterprise products approved — e.g. set + // `LINKEDIN_EXTRA_SCOPES=r_basicprofile` to re-enable vanityName + // lookup via /v2/me. + 'extra_scopes' => env('LINKEDIN_EXTRA_SCOPES'), ], 'linkedin-openid' => [ diff --git a/docker/.env.docker.example b/docker/.env.docker.example index aaacd5fd..e91739af 100644 --- a/docker/.env.docker.example +++ b/docker/.env.docker.example @@ -90,6 +90,11 @@ LINKEDIN_CLIENT_ID= LINKEDIN_CLIENT_SECRET= LINKEDIN_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin/callback" LINKEDIN_PAGE_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin-page/callback" +# Optional: comma-separated extra OAuth scopes appended to the personal +# LinkedIn connect flow. Use this if your LinkedIn dev app has legacy or +# enterprise products approved — e.g. `r_basicprofile` re-enables +# vanityName lookup via /v2/me. Leave empty for the safe default set. +# LINKEDIN_EXTRA_SCOPES=r_basicprofile X_CLIENT_ID= X_CLIENT_SECRET= diff --git a/tests/Feature/Social/LinkedInControllerTest.php b/tests/Feature/Social/LinkedInControllerTest.php index c95465a7..43fc5cf4 100644 --- a/tests/Feature/Social/LinkedInControllerTest.php +++ b/tests/Feature/Social/LinkedInControllerTest.php @@ -39,6 +39,69 @@ expect(session('social_connect_workspace'))->toBe($this->workspace->id); }); +test('linkedin connect requests the default scope set when LINKEDIN_EXTRA_SCOPES is unset', function () { + config(['services.linkedin.extra_scopes' => null]); + + $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); + + $this->actingAs($this->user) + ->withHeader('X-Inertia', 'true') + ->get(route('app.social.linkedin.connect')); + + expect($captured)->toEqualCanonicalizing([ + 'openid', 'profile', 'email', 'w_member_social', + ]); +}); + +test('linkedin connect appends LINKEDIN_EXTRA_SCOPES to the default scope set', function () { + // Backward-compatibility: ops who have legacy products approved on + // their LinkedIn app (e.g. r_basicprofile) opt back in via env. + config(['services.linkedin.extra_scopes' => 'r_basicprofile, r_emailaddress']); + + $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); + + $this->actingAs($this->user) + ->withHeader('X-Inertia', 'true') + ->get(route('app.social.linkedin.connect')); + + expect($captured)->toEqualCanonicalizing([ + 'openid', 'profile', 'email', 'w_member_social', + 'r_basicprofile', 'r_emailaddress', + ]); +}); + test('linkedin oauth callback creates account', function () { session([ 'social_connect_workspace' => $this->workspace->id, @@ -99,7 +162,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 +179,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', ]); }); From 21bbe96d6bae2d9557f8b8aa460419ce81f90ab8 Mon Sep 17 00:00:00 2001 From: Falconiere Barbosa Date: Wed, 10 Jun 2026 14:54:47 -0300 Subject: [PATCH 2/5] refactor(linkedin): move OAuth scopes into config/trypost.php platforms Consolidate the default scope set alongside the existing LinkedIn host config under config/trypost.php -> platforms.linkedin, matching the project convention that per-platform service config lives there. The default still drops the deprecated r_basicprofile scope, and LINKEDIN_EXTRA_SCOPES stays additive (merged onto the defaults rather than replacing them) so operators can opt back into legacy scopes without risking a misconfigured full-replacement. - config/trypost.php: add scopes + extra_scopes to platforms.linkedin - config/services.php: drop the moved extra_scopes key - LinkedInController::resolveScopes(): read both from trypost config - tests: repoint config() overrides to the new key --- .../Controllers/Auth/LinkedInController.php | 28 +++++++++---------- config/services.php | 6 ---- config/trypost.php | 12 ++++++++ .../Feature/Social/LinkedInControllerTest.php | 4 +-- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/app/Http/Controllers/Auth/LinkedInController.php b/app/Http/Controllers/Auth/LinkedInController.php index c619d210..2a4b0d5e 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -22,13 +22,6 @@ class LinkedInController extends SocialController protected SocialPlatform $platform = SocialPlatform::LinkedIn; - protected array $scopes = [ - 'openid', - 'profile', - 'email', - 'w_member_social', - ]; - public function connect(Request $request): Response|RedirectResponse { $this->ensurePlatformEnabled(); @@ -45,22 +38,29 @@ public function connect(Request $request): Response|RedirectResponse } /** - * Merge $this->scopes with any extra scopes the operator configured via - * `LINKEDIN_EXTRA_SCOPES` — comma-separated, e.g. `r_basicprofile`. Useful - * when the connected LinkedIn dev app has legacy or enterprise products - * not covered by the default Sign-In + Share-on-LinkedIn pair. + * Merge the default LinkedIn scopes with any extra scopes the operator + * configured via `LINKEDIN_EXTRA_SCOPES` — comma-separated, e.g. + * `r_basicprofile`. Useful when the connected LinkedIn dev app has legacy + * or enterprise products not covered by the default Sign-In + + * Share-on-LinkedIn pair. Both live under + * `config/trypost.php` → `platforms.linkedin`. + * + * @return array */ protected function resolveScopes(): array { - $extra = (string) config('services.linkedin.extra_scopes', ''); + /** @var array $scopes */ + $scopes = config('trypost.platforms.linkedin.scopes', []); + + $extra = (string) config('trypost.platforms.linkedin.extra_scopes', ''); if ($extra === '') { - return $this->scopes; + return $scopes; } $extraScopes = array_filter(array_map('trim', explode(',', $extra))); - return array_values(array_unique([...$this->scopes, ...$extraScopes])); + return array_values(array_unique([...$scopes, ...$extraScopes])); } public function callback(Request $request): View diff --git a/config/services.php b/config/services.php index f920d9ed..b6c194cc 100644 --- a/config/services.php +++ b/config/services.php @@ -41,12 +41,6 @@ 'client_id' => env('LINKEDIN_CLIENT_ID'), 'client_secret' => env('LINKEDIN_CLIENT_SECRET'), 'redirect' => env('LINKEDIN_CLIENT_REDIRECT'), - // Comma-separated list of OAuth scopes to request beyond the defaults - // (openid, profile, email, w_member_social). Useful for apps with - // legacy or enterprise products approved — e.g. set - // `LINKEDIN_EXTRA_SCOPES=r_basicprofile` to re-enable vanityName - // lookup via /v2/me. - 'extra_scopes' => env('LINKEDIN_EXTRA_SCOPES'), ], 'linkedin-openid' => [ diff --git a/config/trypost.php b/config/trypost.php index 0b1788ae..39882455 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -80,6 +80,18 @@ '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'), + // Default OAuth scopes requested on /connect/linkedin. Covers the + // two products auto-approved for new apps: Sign In with LinkedIn + // (openid, profile, email) + Share on LinkedIn (w_member_social). + // `r_basicprofile` is intentionally omitted — it's a 2018-deprecated + // legacy scope that new apps can't grant, and requesting it makes + // LinkedIn reject the whole authorize request. + 'scopes' => ['openid', 'profile', 'email', 'w_member_social'], + // Comma-separated extra scopes merged onto the defaults above, for + // apps with legacy/enterprise products approved — e.g. + // `LINKEDIN_EXTRA_SCOPES=r_basicprofile` to re-enable the vanityName + // lookup via /v2/me. + 'extra_scopes' => env('LINKEDIN_EXTRA_SCOPES'), ], 'linkedin-page' => [ 'enabled' => env('LINKEDIN_PAGE_ENABLED', true), diff --git a/tests/Feature/Social/LinkedInControllerTest.php b/tests/Feature/Social/LinkedInControllerTest.php index 43fc5cf4..768a9f01 100644 --- a/tests/Feature/Social/LinkedInControllerTest.php +++ b/tests/Feature/Social/LinkedInControllerTest.php @@ -40,7 +40,7 @@ }); test('linkedin connect requests the default scope set when LINKEDIN_EXTRA_SCOPES is unset', function () { - config(['services.linkedin.extra_scopes' => null]); + config(['trypost.platforms.linkedin.extra_scopes' => null]); $captured = []; @@ -72,7 +72,7 @@ test('linkedin connect appends LINKEDIN_EXTRA_SCOPES to the default scope set', function () { // Backward-compatibility: ops who have legacy products approved on // their LinkedIn app (e.g. r_basicprofile) opt back in via env. - config(['services.linkedin.extra_scopes' => 'r_basicprofile, r_emailaddress']); + config(['trypost.platforms.linkedin.extra_scopes' => 'r_basicprofile, r_emailaddress']); $captured = []; From 8e164bda36e4c4d2a4263c72940c14f8cbe23620 Mon Sep 17 00:00:00 2001 From: Falconiere Barbosa Date: Wed, 10 Jun 2026 14:56:44 -0300 Subject: [PATCH 3/5] refactor(linkedin): dedupe connect-scope test setup into shared closure --- .../Feature/Social/LinkedInControllerTest.php | 46 +++++++------------ 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/tests/Feature/Social/LinkedInControllerTest.php b/tests/Feature/Social/LinkedInControllerTest.php index 768a9f01..ef05246d 100644 --- a/tests/Feature/Social/LinkedInControllerTest.php +++ b/tests/Feature/Social/LinkedInControllerTest.php @@ -39,9 +39,13 @@ expect(session('social_connect_workspace'))->toBe($this->workspace->id); }); -test('linkedin connect requests the default scope set when LINKEDIN_EXTRA_SCOPES is unset', function () { - config(['trypost.platforms.linkedin.extra_scopes' => null]); - +/** + * 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(); @@ -60,43 +64,27 @@ ->with('linkedin') ->andReturn($driverMock); - $this->actingAs($this->user) + $test->actingAs($test->user) ->withHeader('X-Inertia', 'true') ->get(route('app.social.linkedin.connect')); - expect($captured)->toEqualCanonicalizing([ + return $captured; +}; + +test('linkedin connect requests the default scope set when LINKEDIN_EXTRA_SCOPES is unset', function () use ($captureConnectScopes) { + config(['trypost.platforms.linkedin.extra_scopes' => null]); + + expect($captureConnectScopes($this))->toEqualCanonicalizing([ 'openid', 'profile', 'email', 'w_member_social', ]); }); -test('linkedin connect appends LINKEDIN_EXTRA_SCOPES to the default scope set', function () { +test('linkedin connect appends LINKEDIN_EXTRA_SCOPES to the default scope set', function () use ($captureConnectScopes) { // Backward-compatibility: ops who have legacy products approved on // their LinkedIn app (e.g. r_basicprofile) opt back in via env. config(['trypost.platforms.linkedin.extra_scopes' => 'r_basicprofile, r_emailaddress']); - $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); - - $this->actingAs($this->user) - ->withHeader('X-Inertia', 'true') - ->get(route('app.social.linkedin.connect')); - - expect($captured)->toEqualCanonicalizing([ + expect($captureConnectScopes($this))->toEqualCanonicalizing([ 'openid', 'profile', 'email', 'w_member_social', 'r_basicprofile', 'r_emailaddress', ]); From a2a7f44c9ad25ef68eea002ce2e6c242ab959ca7 Mon Sep 17 00:00:00 2001 From: Falconiere Barbosa Date: Wed, 10 Jun 2026 15:02:00 -0300 Subject: [PATCH 4/5] refactor(linkedin): drive OAuth scopes from LINKEDIN_SCOPES env via config Replace the additive LINKEDIN_EXTRA_SCOPES approach with a single full-override env var per flow, exploded into an array at the config layer (config/trypost.php -> platforms.linkedin{,-page}.scopes). This keeps env values as plain comma-separated strings, lets self-hosters override the entire set in one place, and removes the controller-side scope-merge logic. - config/trypost.php: explode LINKEDIN_SCOPES / LINKEDIN_PAGE_SCOPES into the scopes arrays (deprecated r_basicprofile stays out of the personal default) - LinkedInController: drop resolveScopes(), read config scopes directly - LinkedInPageController: drop the hardcoded $scopes property, read config scopes at both call sites - tests: drive the connect scope assertions from config overrides - .env.example, docker/.env.docker.example: document LINKEDIN_SCOPES and LINKEDIN_PAGE_SCOPES --- .env.example | 11 ++++---- .../Controllers/Auth/LinkedInController.php | 28 +------------------ .../Auth/LinkedInPageController.php | 14 ++-------- config/trypost.php | 25 +++++++++-------- docker/.env.docker.example | 11 ++++---- .../Feature/Social/LinkedInControllerTest.php | 16 +++++------ 6 files changed, 36 insertions(+), 69 deletions(-) diff --git a/.env.example b/.env.example index 3ac86820..d2ad753b 100644 --- a/.env.example +++ b/.env.example @@ -103,11 +103,12 @@ LINKEDIN_CLIENT_ID= LINKEDIN_CLIENT_SECRET= LINKEDIN_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin/callback" LINKEDIN_PAGE_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin-page/callback" -# Optional: comma-separated extra OAuth scopes appended to the personal -# LinkedIn connect flow. Use this if your LinkedIn dev app has legacy or -# enterprise products approved — e.g. `r_basicprofile` re-enables -# vanityName lookup via /v2/me. Leave empty for the safe default set. -# LINKEDIN_EXTRA_SCOPES=r_basicprofile +# Optional: comma-separated OAuth scopes for each LinkedIn connect flow. +# Override these only if your LinkedIn dev app has legacy/enterprise products +# approved — e.g. add `r_basicprofile` to the personal set to re-enable the +# vanityName lookup via /v2/me. Leave unset to use the safe defaults. +# LINKEDIN_SCOPES="openid,profile,email,w_member_social" +# 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 2a4b0d5e..a493d725 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -34,33 +34,7 @@ public function connect(Request $request): Response|RedirectResponse $this->authorize('manageAccounts', $workspace); - return $this->redirectToProvider($request, $this->driver, $this->resolveScopes()); - } - - /** - * Merge the default LinkedIn scopes with any extra scopes the operator - * configured via `LINKEDIN_EXTRA_SCOPES` — comma-separated, e.g. - * `r_basicprofile`. Useful when the connected LinkedIn dev app has legacy - * or enterprise products not covered by the default Sign-In + - * Share-on-LinkedIn pair. Both live under - * `config/trypost.php` → `platforms.linkedin`. - * - * @return array - */ - protected function resolveScopes(): array - { - /** @var array $scopes */ - $scopes = config('trypost.platforms.linkedin.scopes', []); - - $extra = (string) config('trypost.platforms.linkedin.extra_scopes', ''); - - if ($extra === '') { - return $scopes; - } - - $extraScopes = array_filter(array_map('trim', explode(',', $extra))); - - return array_values(array_unique([...$scopes, ...$extraScopes])); + 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 39882455..b5d35932 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -80,22 +80,23 @@ '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'), - // Default OAuth scopes requested on /connect/linkedin. Covers the - // two products auto-approved for new apps: Sign In with LinkedIn - // (openid, profile, email) + Share on LinkedIn (w_member_social). - // `r_basicprofile` is intentionally omitted — it's a 2018-deprecated - // legacy scope that new apps can't grant, and requesting it makes - // LinkedIn reject the whole authorize request. - 'scopes' => ['openid', 'profile', 'email', 'w_member_social'], - // Comma-separated extra scopes merged onto the defaults above, for - // apps with legacy/enterprise products approved — e.g. - // `LINKEDIN_EXTRA_SCOPES=r_basicprofile` to re-enable the vanityName - // lookup via /v2/me. - 'extra_scopes' => env('LINKEDIN_EXTRA_SCOPES'), + // Comma-separated OAuth scopes requested on /connect/linkedin. + // Default covers the two products auto-approved for new apps: Sign + // In with LinkedIn (openid, profile, email) + Share on LinkedIn + // (w_member_social). The 2018-deprecated `r_basicprofile` is + // intentionally absent — new apps can't grant it, and requesting it + // makes LinkedIn reject the whole authorize request. Override via + // LINKEDIN_SCOPES if your app has legacy/enterprise products + // approved (e.g. add `r_basicprofile` to re-enable the vanityName + // lookup via /v2/me). + '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'), + // Comma-separated OAuth scopes requested on the LinkedIn Company + // Page connect flow. Override via LINKEDIN_PAGE_SCOPES. + '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 e91739af..9395e6f8 100644 --- a/docker/.env.docker.example +++ b/docker/.env.docker.example @@ -90,11 +90,12 @@ LINKEDIN_CLIENT_ID= LINKEDIN_CLIENT_SECRET= LINKEDIN_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin/callback" LINKEDIN_PAGE_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin-page/callback" -# Optional: comma-separated extra OAuth scopes appended to the personal -# LinkedIn connect flow. Use this if your LinkedIn dev app has legacy or -# enterprise products approved — e.g. `r_basicprofile` re-enables -# vanityName lookup via /v2/me. Leave empty for the safe default set. -# LINKEDIN_EXTRA_SCOPES=r_basicprofile +# Optional: comma-separated OAuth scopes for each LinkedIn connect flow. +# Override these only if your LinkedIn dev app has legacy/enterprise products +# approved — e.g. add `r_basicprofile` to the personal set to re-enable the +# vanityName lookup via /v2/me. Leave unset to use the safe defaults. +# LINKEDIN_SCOPES="openid,profile,email,w_member_social" +# 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 ef05246d..02ce1a50 100644 --- a/tests/Feature/Social/LinkedInControllerTest.php +++ b/tests/Feature/Social/LinkedInControllerTest.php @@ -71,22 +71,22 @@ return $captured; }; -test('linkedin connect requests the default scope set when LINKEDIN_EXTRA_SCOPES is unset', function () use ($captureConnectScopes) { - config(['trypost.platforms.linkedin.extra_scopes' => null]); +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 appends LINKEDIN_EXTRA_SCOPES to the default scope set', function () use ($captureConnectScopes) { - // Backward-compatibility: ops who have legacy products approved on - // their LinkedIn app (e.g. r_basicprofile) opt back in via env. - config(['trypost.platforms.linkedin.extra_scopes' => 'r_basicprofile, r_emailaddress']); +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', 'r_emailaddress', + 'openid', 'profile', 'email', 'w_member_social', 'r_basicprofile', ]); }); From 2b758205448a945cc838a81139249cd10113bec2 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 10 Jun 2026 16:20:00 -0300 Subject: [PATCH 5/5] chore: small improvements --- .env.example | 7 ++----- config/trypost.php | 13 ++----------- docker/.env.docker.example | 6 +----- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index d2ad753b..fc3ed8b5 100644 --- a/.env.example +++ b/.env.example @@ -102,12 +102,9 @@ SPACES_BUCKET= LINKEDIN_CLIENT_ID= LINKEDIN_CLIENT_SECRET= LINKEDIN_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin/callback" -LINKEDIN_PAGE_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin-page/callback" -# Optional: comma-separated OAuth scopes for each LinkedIn connect flow. -# Override these only if your LinkedIn dev app has legacy/enterprise products -# approved — e.g. add `r_basicprofile` to the personal set to re-enable the -# vanityName lookup via /v2/me. Leave unset to use the safe defaults. # 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) diff --git a/config/trypost.php b/config/trypost.php index b5d35932..a541cd47 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -80,22 +80,13 @@ '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'), - // Comma-separated OAuth scopes requested on /connect/linkedin. - // Default covers the two products auto-approved for new apps: Sign - // In with LinkedIn (openid, profile, email) + Share on LinkedIn - // (w_member_social). The 2018-deprecated `r_basicprofile` is - // intentionally absent — new apps can't grant it, and requesting it - // makes LinkedIn reject the whole authorize request. Override via - // LINKEDIN_SCOPES if your app has legacy/enterprise products - // approved (e.g. add `r_basicprofile` to re-enable the vanityName - // lookup via /v2/me). + // 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'), - // Comma-separated OAuth scopes requested on the LinkedIn Company - // Page connect flow. Override via LINKEDIN_PAGE_SCOPES. + // 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' => [ diff --git a/docker/.env.docker.example b/docker/.env.docker.example index 9395e6f8..5d8e48b6 100644 --- a/docker/.env.docker.example +++ b/docker/.env.docker.example @@ -89,12 +89,8 @@ R2_URL= LINKEDIN_CLIENT_ID= LINKEDIN_CLIENT_SECRET= LINKEDIN_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin/callback" -LINKEDIN_PAGE_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin-page/callback" -# Optional: comma-separated OAuth scopes for each LinkedIn connect flow. -# Override these only if your LinkedIn dev app has legacy/enterprise products -# approved — e.g. add `r_basicprofile` to the personal set to re-enable the -# vanityName lookup via /v2/me. Leave unset to use the safe defaults. # 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=