Skip to content
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
10 changes: 1 addition & 9 deletions app/Http/Controllers/Auth/LinkedInController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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'));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveScopes() is gone — connect() reads the resolved array straight from config. All scope logic (default + override) now lives in config/trypost.php, nothing to merge here.

}

public function callback(Request $request): View
Expand Down
14 changes: 2 additions & 12 deletions app/Http/Controllers/Auth/LinkedInPageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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'))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped the hardcoded protected array $scopes property; both the connect (here) and callback call sites now read config('trypost.platforms.linkedin-page.scopes'). Single source of truth, page scopes now env-overridable.

->with([
'redirect_uri' => config('services.linkedin-openid.redirect_page'),
])
Expand All @@ -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'),
])
Expand Down
4 changes: 4 additions & 0 deletions config/trypost.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'))))),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personal connect scopes. LINKEDIN_SCOPES is a single CSV string exploded here, so env stays a plain string and config returns an array — no array-in-.env problem. r_basicprofile is intentionally out of the default (deprecated 2018; new apps can't grant it → LinkedIn rejects the whole authorize request).

Note this is full-replace, not additive: an operator who sets LINKEDIN_SCOPES must list every scope. Forgetting w_member_social silently breaks publishing. That's the trade-off vs the earlier additive LINKEDIN_EXTRA_SCOPES — flagging so it's a conscious choice. The array_values(array_filter(...)) guards against blank tokens / trailing commas in the env value.

],
'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'))))),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Company Pages scopes — added per your request to cover both flows in this PR. Same explode pattern, overridable via LINKEDIN_PAGE_SCOPES. Default is the exact set the page controller used before (w_organization_social, r_organization_social, rw_organization_admin, …), so behaviour is unchanged when the env var is unset.

],
'x' => [
'enabled' => env('X_ENABLED', true),
Expand Down
2 changes: 2 additions & 0 deletions docker/.env.docker.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
55 changes: 53 additions & 2 deletions tests/Feature/Social/LinkedInControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, string>
*/
$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']]);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two connect tests drive scopes via config([...]) override: one asserts the safe default set, one asserts a custom set with r_basicprofile re-added (proves the LINKEDIN_SCOPES override path end-to-end). Shared mock setup is in the $captureConnectScopes closure above (line 48) to avoid duplication.


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,
Expand Down Expand Up @@ -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'];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating the scope list in the fixture to match the new default — the CSV-normalization logic this test covers is unchanged.


Socialite::shouldReceive('driver')
->with('linkedin')
Expand All @@ -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',
]);
});

Expand Down
Loading