fix(linkedin): drop deprecated r_basicprofile from default scopes#68
Conversation
|
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. |
|
I will dig a bit more to make it backward compatible |
b296faa to
3472841
Compare
| 'profile', | ||
| 'email', | ||
| 'r_basicprofile', | ||
| 'w_member_social', |
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| // 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'), |
There was a problem hiding this comment.
null (env unset) → controller's early-return path → defaults unchanged. No new required config key.
| # 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 |
There was a problem hiding this comment.
Commented out so the safe default takes effect on fresh installs. Ops whose LinkedIn app has legacy products approved uncomment this single line.
| # 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 |
There was a problem hiding this comment.
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 () { |
There was a problem hiding this comment.
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 () { |
There was a problem hiding this comment.
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']; |
There was a problem hiding this comment.
Updating the scope list in the fixture to match the new default — the CSV-normalization logic this test covers is unchanged.
|
@Falconiere i got it, i will create a new app to reproduce and test your pull request locally. |
5252689 to
af12a68
Compare
af12a68 to
133a15b
Compare
|
Hey @Falconiere I'm so sorry to delay, any reason why to closed that? |
Hey @paulocastellano, I was closing stale PRs; I will reopen :) |
|
Reopen stale PRs; |
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.
133a15b to
410eb96
Compare
|
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 |
|
@Falconiere i'm thinking on a better solution for that, move all linkedin scopes to 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 |
|
@Falconiere awesome. You need use string in LINKEDIN_SCOPED="openid,profile,etc" But into 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! |
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
left a comment
There was a problem hiding this comment.
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.
pint + php artisan test --filter=LinkedIn are green before merge.
| // 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'))))), |
There was a problem hiding this comment.
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.
| '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'))))), |
There was a problem hiding this comment.
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')); |
There was a problem hiding this comment.
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')) |
There was a problem hiding this comment.
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']]); |
There was a problem hiding this comment.
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.
|
@paulocastellano done — pushed your approach in the same PR:
One thing to confirm: Couldn't run the suite locally (no PHP in my env) — mind confirming |
|
@Falconiere sure, let me test it locally. |
|
I just updated the comments, but it's ok... let's merge it. |
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_basicprofileis 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_basicprofilefrom the default scope set and adds aLINKEDIN_EXTRA_SCOPESenv var so ops who DO have legacy or enterprise products approved keep working unchanged.Reproducer (before this PR)
services.linkedin.client_id/_secretfilled in.The OAuth URL trypost emits includes
scope=openid+profile+email+r_basicprofile+w_member_social. LinkedIn refuses to issue an auth code becauser_basicprofileis not covered by the app's approved products.Default scope set after this PR
openid,profile,emailw_member_socialThat'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:LinkedInController::resolveScopes()merges this comma-separated list into the default scope array. The connect flow'sSocialite::scopes()call then includes the legacy scope, preserving pre-PR behaviour end-to-end — including the/v2/me?projection=(vanityName)lookup and the prettylinkedin.com/in/<slug>URLs.Impact on users without
r_basicprofile/connect/linkedinusernamevanityNamefrom/v2/menull(graceful —fetchVanityName()already returns null on HTTP failure)LinkedInPagePublisherpost URLlinkedin.com/company/<vanity>/posts/linkedin.com/feed/update/<id>(the publisher already falls back when$account->usernameis null)Files
app/Http/Controllers/Auth/LinkedInController.php— remover_basicprofilefrom the default$scopes, addresolveScopes()that appendsLINKEDIN_EXTRA_SCOPES.config/services.php— addextra_scopeskey to thelinkedinprovider config..env.example,docker/.env.docker.example— documentLINKEDIN_EXTRA_SCOPES(commented out by default).tests/Feature/Social/LinkedInControllerTest.phprequests the default scope set when LINKEDIN_EXTRA_SCOPES is unsetappends LINKEDIN_EXTRA_SCOPES to the default scope set(usesr_basicprofile, r_emailaddressto verify the comma-split-and-trim path)splits comma-separated approvedScopesfixture's scope list now matches the new default.Test plan
/connect/linkedincompletes, account row created,usernameis null, post URLs use the/feed/update/<id>form.r_basicprofileapproved,LINKEDIN_EXTRA_SCOPES=r_basicprofileset → connect completes with the legacy scope in the consent screen,usernameis populated from/v2/me, post URLs use the vanity slug.🤖 Generated with Claude Code