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
This commit is contained in:
Falconiere Barbosa 2026-06-10 15:02:00 -03:00
parent 8e164bda36
commit a2a7f44c9a
No known key found for this signature in database
GPG key ID: A25861196368D204
6 changed files with 36 additions and 69 deletions

View file

@ -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=

View file

@ -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<int, string>
*/
protected function resolveScopes(): array
{
/** @var array<int, string> $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

View file

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

View file

@ -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),

View file

@ -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=

View file

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