Skip to content

fix(linkedin): drop deprecated r_basicprofile from default scopes#68

Merged
paulocastellano merged 8 commits into
trypostit:mainfrom
Falconiere:fix/linkedin-drop-r_basicprofile
Jun 10, 2026
Merged

fix(linkedin): drop deprecated r_basicprofile from default scopes#68
paulocastellano merged 8 commits into
trypostit:mainfrom
Falconiere:fix/linkedin-drop-r_basicprofile

Conversation

@Falconiere

@Falconiere Falconiere commented May 28, 2026

Copy link
Copy Markdown
Contributor

Summary

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 developer apps don't have it, so every self-hosted user hits the rejection immediately on /connect/linkedin.

This PR removes r_basicprofile from the default scope set and adds a LINKEDIN_EXTRA_SCOPES env var so ops who DO have legacy or enterprise products approved keep working unchanged.

Reproducer (before this PR)

  1. Fresh LinkedIn dev app with the standard self-hosted products: Sign In with LinkedIn using OpenID Connect + Share on LinkedIn.
  2. services.linkedin.client_id / _secret filled in.
  3. Connect LinkedIn from the trypost UI → LinkedIn auth page → generic "Bummer" error.

The OAuth URL trypost emits includes scope=openid+profile+email+r_basicprofile+w_member_social. LinkedIn refuses to issue an auth code because r_basicprofile is not covered by the app's approved products.

Default scope set after this PR

Product Granted scopes
Sign In with LinkedIn using OpenID Connect openid, profile, email
Share on LinkedIn w_member_social

That's what trypost requests by default. Both products are auto-approved for new apps — no review form needed.

Backward compatibility — opt back in to legacy scopes

Ops whose LinkedIn app has legacy/enterprise products approved (so they DO have r_basicprofile, r_emailaddress, etc.) can re-add scopes 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 pre-PR behaviour end-to-end — including the /v2/me?projection=(vanityName) lookup and the pretty linkedin.com/in/<slug> URLs.

Impact on users without r_basicprofile

What Before After
/connect/linkedin LinkedIn 400 / "Bummer" page OAuth completes ✓
Post publishing Worked once you got past connect Works ✓
Connected account username vanityName from /v2/me null (graceful — fetchVanityName() already returns null on HTTP failure)
LinkedInPagePublisher post URL linkedin.com/company/<vanity>/posts/ linkedin.com/feed/update/<id> (the publisher already falls back when $account->username is null)

Files

  • app/Http/Controllers/Auth/LinkedInController.php — remove r_basicprofile from the default $scopes, add resolveScopes() that appends LINKEDIN_EXTRA_SCOPES.
  • config/services.php — add extra_scopes key to the linkedin provider config.
  • .env.example, docker/.env.docker.example — document LINKEDIN_EXTRA_SCOPES (commented out by default).
  • tests/Feature/Social/LinkedInControllerTest.php
    • New: requests the default scope set when LINKEDIN_EXTRA_SCOPES is unset
    • New: appends LINKEDIN_EXTRA_SCOPES to the default scope set (uses r_basicprofile, r_emailaddress to verify the comma-split-and-trim path)
    • Updated: the existing splits comma-separated approvedScopes fixture's scope list now matches the new default.

Test plan

  • CI: `composer lint` (pint) passes
  • CI: `composer test` (Pest) passes
  • Manual A — default: fresh LinkedIn app with only OpenID Connect + Share-on-LinkedIn → /connect/linkedin completes, account row created, username is null, post URLs use the /feed/update/<id> form.
  • Manual B — legacy: app that has r_basicprofile approved, LINKEDIN_EXTRA_SCOPES=r_basicprofile set → connect completes with the legacy scope in the consent screen, username is populated from /v2/me, post URLs use the vanity slug.

🤖 Generated with Claude Code

@paulocastellano

paulocastellano commented May 28, 2026

Copy link
Copy Markdown
Contributor

@Falconiere

You LinkedIn app it's new? I'm asking that because it's working fine with TryPost production app.

@Falconiere

Falconiere commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

@Falconiere

You LinkedIn app it's new? I'm asking that because it's working fine with TryPost production app.

Hey, yes! my account account is new, this is what got from claude:

That generic "Bummer" page is LinkedIn rejecting the OAuth scope set. The likely culprit is r_basicprofile — a legacy scope deprecated in 2018 that modern apps don't have access to (it's not part of Sign-In-with-LinkedIn or Share-on-LinkedIn products). LinkedIn responds with the generic error when an app requests a scope it can't grant.

@Falconiere

Falconiere commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

I will dig a bit more to make it backward compatible

@Falconiere Falconiere force-pushed the fix/linkedin-drop-r_basicprofile branch 2 times, most recently from b296faa to 3472841 Compare May 28, 2026 16:13
@Falconiere Falconiere changed the title fix(linkedin): drop deprecated r_basicprofile scope fix(linkedin): drop deprecated r_basicprofile from default scopes May 28, 2026
'profile',
'email',
'r_basicprofile',
'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.

Removed 'r_basicprofile' from this list. Legacy scope (deprecated 2018) — new LinkedIn dev apps can't request it, and LinkedIn rejects the whole authorize request with a generic error page when an unsupported scope appears. Ops with the legacy product approved re-add it via LINKEDIN_EXTRA_SCOPES.

$this->authorize('manageAccounts', $workspace);

return $this->redirectToProvider($request, $this->driver, $this->scopes);
return $this->redirectToProvider($request, $this->driver, $this->resolveScopes());

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.

Was $this->scopes; now resolveScopes() so the env-driven extras get appended.

* 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

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.

Early-returns $this->scopes when the env is unset — behaviour is byte-identical for everyone who doesn't opt in. array_filter(array_map('trim', ...)) tolerates trailing commas and whitespace; array_unique makes accidental overlaps with the defaults a no-op.

Comment thread config/services.php Outdated
// 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'),

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.

null (env unset) → controller's early-return path → defaults unchanged. No new required config key.

Comment thread .env.example Outdated
# 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

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.

Commented out so the safe default takes effect on fresh installs. Ops whose LinkedIn app has legacy products approved uncomment this single line.

Comment thread docker/.env.docker.example Outdated
# 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

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.

Mirrors .env.example so the Docker-bootstrapped template documents the option too.

expect(session('social_connect_workspace'))->toBe($this->workspace->id);
});

test('linkedin connect requests the default scope set when LINKEDIN_EXTRA_SCOPES is unset', function () {

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.

Regression guard: if r_basicprofile (or any other legacy scope) ever sneaks back into the controller's default $scopes, this test fails.

]);
});

test('linkedin connect appends LINKEDIN_EXTRA_SCOPES to the default scope set', function () {

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.

Uses r_basicprofile, r_emailaddress (note the space after the comma) to cover trim + multi-value parsing in one assertion.

// 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.

@paulocastellano

Copy link
Copy Markdown
Contributor

@Falconiere i got it, i will create a new app to reproduce and test your pull request locally.

@Falconiere Falconiere force-pushed the fix/linkedin-drop-r_basicprofile branch 2 times, most recently from 5252689 to af12a68 Compare June 3, 2026 15:37
@Falconiere Falconiere force-pushed the fix/linkedin-drop-r_basicprofile branch from af12a68 to 133a15b Compare June 7, 2026 19:42
@Falconiere Falconiere closed this Jun 7, 2026
@Falconiere Falconiere deleted the fix/linkedin-drop-r_basicprofile branch June 7, 2026 22:53
@paulocastellano

Copy link
Copy Markdown
Contributor

Hey @Falconiere

I'm so sorry to delay, any reason why to closed that?

@Falconiere

Copy link
Copy Markdown
Contributor Author

Hey @Falconiere

I'm so sorry to delay, any reason why to closed that?

Hey @paulocastellano, I was closing stale PRs; I will reopen :)

@Falconiere

Copy link
Copy Markdown
Contributor Author

Reopen stale PRs;

@Falconiere Falconiere restored the fix/linkedin-drop-r_basicprofile branch June 10, 2026 16:39
@Falconiere Falconiere reopened this Jun 10, 2026
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/<slug>`). `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/<id>` 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.
@Falconiere Falconiere force-pushed the fix/linkedin-drop-r_basicprofile branch from 133a15b to 410eb96 Compare June 10, 2026 16:51
@paulocastellano

paulocastellano commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

@Falconiere

It not will affect company profiles right? Because currently we use the same flow.

We already have a issue for split it: #73

@Falconiere

Copy link
Copy Markdown
Contributor Author

@Falconiere

It not will affect company profiles right? Because currently we use the same flow.

We already have a issue for split it: #73

No, it will not

@paulocastellano

paulocastellano commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

@Falconiere i'm thinking on a better solution for that, move all linkedin scopes to config/trypost.php file.

'linkedin' => [
    'enabled' => env('LINKEDIN_ENABLED', true),
    'api' => env('LINKEDIN_API', 'https://api.linkedin.com'),
    'oauth_api' => env('LINKEDIN_OAUTH_API', 'https://www.linkedin.com'),
    'scopes' => array_values(array_filter(array_map('trim', explode(',',
          env('LINKEDIN_SCOPES', 'openid,profile,email,w_member_social,r_basicprofile')
      )))),
],

It solve our problem, keep the default project, and more simple.

What you think?

@Falconiere

Copy link
Copy Markdown
Contributor Author

@Falconiere i'm thinking on a better solution for that, move all linkedin scopes to config/trypost.php file.

'linkedin' => [
    'enabled' => env('LINKEDIN_ENABLED', true),
    'api' => env('LINKEDIN_API', 'https://api.linkedin.com'),
    'oauth_api' => env('LINKEDIN_OAUTH_API', 'https://www.linkedin.com'),
    'scopes' => env('LINKEDIN_SCOPES', ['openid','profile','email','r_basicprofile','w_member_social']),
],

It solve our problem, keep the default project, and more simple.

What you think?

@paulocastellano it's a good call; that's where the LinkedIn host config already lives (api, oauth_api), so scopes belong there too; I will submit the update

@paulocastellano

paulocastellano commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

@Falconiere awesome.

You need use string in .ENV like:

LINKEDIN_SCOPED="openid,profile,etc"

But into config/trypost.php you need explode it ;)

So you don't need change any test or resolveScopes.

If you can push in the same PR configs for both, LinkedIn Personal + Company Pages will be good!

paulocastellano and others added 3 commits June 10, 2026 14:57
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
…onfig

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

@Falconiere Falconiere left a comment

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.

Refactor per the discussion: LinkedIn OAuth scopes now come from LINKEDIN_SCOPES / LINKEDIN_PAGE_SCOPES (comma-separated env strings, exploded into arrays in config), and both controllers read config directly instead of hardcoded properties. Left inline notes on the key decisions to make review faster.

⚠️ Heads up: tests weren't run on my end (no PHP toolchain in my env) — please confirm pint + php artisan test --filter=LinkedIn are green before merge.

Comment thread config/trypost.php
// 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'))))),

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.

Comment thread config/trypost.php
'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'))))),

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.

$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.

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.

// 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.

@Falconiere

Copy link
Copy Markdown
Contributor Author

@paulocastellano done — pushed your approach in the same PR:

  • LINKEDIN_SCOPES (personal) + LINKEDIN_PAGE_SCOPES (Company Pages) are now plain comma-separated env strings, exploded into arrays in config/trypost.phpplatforms.linkedin{,-page}.scopes. Env stays a string, config returns the array.
  • Both controllers read config(...) directly — resolveScopes() removed, and LinkedInPageController's hardcoded $scopes property dropped (both call sites now config-driven).
  • Personal default keeps r_basicprofile out; page default is the exact set it used before, so unset = unchanged behaviour.
  • Tests updated to drive scopes via config override; left inline notes on the diff to speed up review.

One thing to confirm: LINKEDIN_SCOPES is full-replace, so an operator who sets it must list every scope (forget w_member_social → publishing breaks). Fine by me, just calling it out.

Couldn't run the suite locally (no PHP in my env) — mind confirming pint + php artisan test --filter=LinkedIn are green?

@paulocastellano

Copy link
Copy Markdown
Contributor

@Falconiere sure, let me test it locally.

@paulocastellano

Copy link
Copy Markdown
Contributor

I just updated the comments, but it's ok... let's merge it.

@paulocastellano paulocastellano merged commit 7178f8e into trypostit:main Jun 10, 2026
2 checks passed
@Falconiere Falconiere deleted the fix/linkedin-drop-r_basicprofile branch June 10, 2026 23:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants