Skip to content

Commit 5e3df88

Browse files
feat(accounts): add dedicated OAuth flow for LinkedIn Pages (#152)
* feat(accounts): add dedicated OAuth flow for LinkedIn Pages LinkedIn Pages (Organizations) now connect through a new LinkedInPageOAuthController backed by a separate Community Management developer app (services.linkedin-pages), since LinkedIn requires that product to be the only one on its app and it can't share the personal linkedin-openid app. Personal profile connection/selection logic is removed from OAuthConnectionController and LinkedInPageConnectionController, which now only handles organization selection. TokenManager refreshes LinkedIn organization accounts against the new linkedin-pages config, and the accounts index/connect UI exposes whether Pages connection is enabled and configured. * fix(accounts): gate LinkedIn Pages reconnect on community mgmt toggle Reconnecting a LinkedIn Pages account now checks the instance "LinkedIn Pages" (Community Management) toggle in addition to app config, and the frontend reconnect link routes org accounts through the dedicated LinkedInPageOAuthController redirect. * fix(linkedin): keep Rest.li List() literal in Pages discovery and metrics Passing `List(...)` through a query array percent-encoded it, so the organizations batch lookup returned HTTP 400 and the Pages connect flow reported "no Pages you administer". Share statistics had the same bug. Build these URLs as raw strings, encoding only the values. Discovery now throws on API/network failures instead of returning an empty list, and the callback shows distinct messages for throttling (429) and denied access (403). * fix(accounts): guard LinkedIn Pages reconnection when unconfigured --------- Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
1 parent a33c497 commit 5e3df88

23 files changed

Lines changed: 824 additions & 308 deletions

‎.env.example‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,22 @@ SOCIALITE_ENABLED=false
7373
# X_REDIRECT_URI="${APP_URL}/accounts/callback/x"
7474
# X_BEARER_TOKEN=
7575

76-
# LinkedIn — scopes: openid, profile, email, w_member_social
76+
# LinkedIn (personal profiles) — "Sign In with LinkedIn using OpenID Connect" +
77+
# "Share on LinkedIn" products. Scopes: openid, profile, email, w_member_social.
7778
# LINKEDIN_CLIENT_ID=
7879
# LINKEDIN_CLIENT_SECRET=
7980
# LINKEDIN_REDIRECT_URI="${APP_URL}/accounts/callback/linkedin"
8081

82+
# LinkedIn Pages (Company/Organization) — a SEPARATE developer app approved for
83+
# the Community Management API. LinkedIn requires that the Community Management
84+
# product be the ONLY product on its app, so it cannot share the app above.
85+
# Register "${APP_URL}/accounts/callback/linkedin-pages" as an authorized
86+
# redirect URL. Scopes: r_organization_social, w_organization_social, rw_organization_admin.
87+
# Requires the instance "LinkedIn Pages" toggle (Community Management) to be on.
88+
# LINKEDIN_PAGES_CLIENT_ID=
89+
# LINKEDIN_PAGES_CLIENT_SECRET=
90+
# LINKEDIN_PAGES_REDIRECT_URI="${APP_URL}/accounts/callback/linkedin-pages"
91+
8192
# Facebook + Instagram — one Meta app. Scopes: pages_show_list, pages_read_engagement,
8293
# pages_manage_posts, pages_read_user_content, pages_manage_engagement, read_insights,
8394
# business_management, instagram_basic, instagram_content_publish,

‎app/Http/Controllers/ConnectedAccounts/ConnectedAccountController.php‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ public function index(Request $request): Response
7676
'accounts' => $accounts,
7777
'capabilities' => Platform::capabilities(),
7878
'canManage' => $request->user()->can('create', ConnectedAccount::class),
79+
// LinkedIn Pages is a distinct connect entry backed by a separate
80+
// Community Management app; expose whether the operator has opted in
81+
// and configured it so the connect menu can show it (or "Not set up").
82+
'linkedinPagesEnabled' => app(InstanceSettings::class)->linkedinCommunityManagementEnabled(),
83+
'linkedinPagesConfigured' => LinkedInPageOAuthController::pagesAppConfigured(),
7984
]);
8085
}
8186

@@ -140,6 +145,23 @@ public function reconnect(Request $request, ConnectedAccount $account): Redirect
140145
return redirect()->route('accounts.index')->with('success', 'Account reconnected.');
141146
}
142147

148+
// LinkedIn Pages reconnect against the dedicated Community Management app,
149+
// not the personal `linkedin-openid` flow — its token was minted there.
150+
// The Pages redirect route 404s unless BOTH the toggle is on and the app
151+
// is configured, so mirror that here and flash a friendly reason instead
152+
// of bouncing the user into a bare 404.
153+
if ($account->platform === Platform::LinkedIn && $account->isLinkedInOrganization()) {
154+
if (
155+
! app(InstanceSettings::class)->linkedinCommunityManagementEnabled()
156+
|| ! LinkedInPageOAuthController::pagesAppConfigured()
157+
) {
158+
return redirect()->route('accounts.index')
159+
->with('error', 'LinkedIn Pages is not configured for reconnection.');
160+
}
161+
162+
return redirect()->route('accounts.linkedin-pages.redirect');
163+
}
164+
143165
if (! $account->platform->supportsAppPassword()) {
144166
if (! $account->platform->isConfigured()) {
145167
return redirect()->route('accounts.index')

‎app/Http/Controllers/ConnectedAccounts/LinkedInPageConnectionController.php‎

Lines changed: 22 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@
1616
use Illuminate\Validation\Rule;
1717

1818
/**
19-
* Persists the LinkedIn accounts (personal profile + administered Pages) the
20-
* user picked after OAuth. All selected accounts share the same member token —
21-
* LinkedIn mints no per-Page token; the member token acts on Pages it
22-
* administers.
19+
* Persists the LinkedIn Pages (Organizations) the user picked after the
20+
* Community Management OAuth flow (LinkedInPageOAuthController). LinkedIn mints
21+
* no per-Page token; the member token from the dedicated Pages app acts on every
22+
* Page the member administers. Personal profiles never come through here — they
23+
* connect directly via the separate `linkedin-openid` app.
2324
*/
2425
class LinkedInPageConnectionController extends Controller
2526
{
@@ -42,8 +43,8 @@ public function store(Request $request): RedirectResponse
4243

4344
$validated = $request->validate([
4445
'selected' => ['required', 'array', 'min:1'],
45-
'selected.*.type' => ['required', 'string', Rule::in(['person', 'organization'])],
46-
'selected.*.id' => ['required_if:selected.*.type,organization', 'nullable', 'string', Rule::in(array_keys($organizations))],
46+
'selected.*.type' => ['required', 'string', Rule::in(['organization'])],
47+
'selected.*.id' => ['required', 'string', Rule::in(array_keys($organizations))],
4748
]);
4849

4950
$token = (string) ($stash['accessToken'] ?? '');
@@ -57,39 +58,23 @@ public function store(Request $request): RedirectResponse
5758

5859
$user = $request->user();
5960

60-
// Persist every selected account in one transaction so a mid-loop failure
61+
// Persist every selected Page in one transaction so a mid-loop failure
6162
// can't leave the workspace with a partial set of connected accounts.
62-
DB::transaction(function () use ($validated, $organizations, $stash, $token, $refresh, $expiresAt, $granted, $user): void {
63+
DB::transaction(function () use ($validated, $organizations, $token, $refresh, $expiresAt, $granted, $user): void {
6364
foreach ($validated['selected'] as $selection) {
64-
if ($selection['type'] === 'person') {
65-
$person = $stash['person'];
66-
$data = new ConnectedAccountData(
67-
platform: Platform::LinkedIn,
68-
remoteAccountId: (string) $person['remoteAccountId'],
69-
handle: (string) $person['handle'],
70-
displayName: $person['displayName'] ?? null,
71-
avatarUrl: $person['avatarUrl'] ?? null,
72-
authMethod: 'oauth',
73-
accessToken: $token,
74-
refreshToken: $refresh,
75-
capabilities: ['linkedin_account_type' => 'person', 'linkedin_engagement' => in_array('r_member_social_feed', $granted, true)],
76-
tokenExpiresAt: $expiresAt,
77-
);
78-
} else {
79-
$organization = $organizations[$selection['id']];
80-
$data = new ConnectedAccountData(
81-
platform: Platform::LinkedIn,
82-
remoteAccountId: (string) $organization['id'],
83-
handle: (string) $organization['vanityName'],
84-
displayName: (string) $organization['name'],
85-
avatarUrl: null,
86-
authMethod: 'oauth',
87-
accessToken: $token,
88-
refreshToken: $refresh,
89-
capabilities: ['linkedin_account_type' => 'organization', 'linkedin_engagement' => in_array('r_organization_social', $granted, true)],
90-
tokenExpiresAt: $expiresAt,
91-
);
92-
}
65+
$organization = $organizations[$selection['id']];
66+
$data = new ConnectedAccountData(
67+
platform: Platform::LinkedIn,
68+
remoteAccountId: (string) $organization['id'],
69+
handle: (string) $organization['vanityName'],
70+
displayName: (string) $organization['name'],
71+
avatarUrl: null,
72+
authMethod: 'oauth',
73+
accessToken: $token,
74+
refreshToken: $refresh,
75+
capabilities: ['linkedin_account_type' => 'organization', 'linkedin_engagement' => in_array('r_organization_social', $granted, true)],
76+
tokenExpiresAt: $expiresAt,
77+
);
9378

9479
$this->connections->store($data, $user);
9580
}
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\ConnectedAccounts;
6+
7+
use App\Http\Controllers\Controller;
8+
use App\Models\ConnectedAccount;
9+
use App\Services\ConnectedAccounts\LinkedIn\LinkedInOrganizationDiscovery;
10+
use App\Services\ConnectedAccounts\LinkedIn\LinkedInOrganizationDiscoveryException;
11+
use App\Support\InstanceSettings;
12+
use Carbon\CarbonImmutable;
13+
use Illuminate\Http\Client\Factory as HttpFactory;
14+
use Illuminate\Http\RedirectResponse;
15+
use Illuminate\Http\Request;
16+
use Illuminate\Support\Facades\Date;
17+
use Illuminate\Support\Facades\Log;
18+
use Illuminate\Support\Str;
19+
use Inertia\Inertia;
20+
use Inertia\Response as InertiaResponse;
21+
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;
22+
23+
/**
24+
* Connects LinkedIn Pages (Organizations) through a SEPARATE developer app
25+
* approved for the Community Management API. LinkedIn requires that product to
26+
* be the only one on its app, so Pages cannot share the personal `linkedin-openid`
27+
* app that `OAuthConnectionController` drives — hence a dedicated OAuth flow with
28+
* its own credentials (`services.linkedin-pages`) and organization-only scopes.
29+
*
30+
* The Community Management app has no OpenID/Sign-In product, so its token can't
31+
* read a member profile via /userinfo. That's fine: the flow only needs the
32+
* member token to enumerate administered Pages and then post as the organization.
33+
* The exchange is therefore done manually (like MetaConnectionController /
34+
* ThreadsTokenExchanger) rather than through Socialite's linkedin-openid driver.
35+
*/
36+
class LinkedInPageOAuthController extends Controller
37+
{
38+
private const string SESSION_KEY = 'accounts.linkedin.connect';
39+
40+
private const string AUTHORIZE_URL = 'https://www.linkedin.com/oauth/v2/authorization';
41+
42+
private const string TOKEN_URL = 'https://www.linkedin.com/oauth/v2/accessToken';
43+
44+
/**
45+
* Organization scopes only — reading/writing Page posts and enumerating the
46+
* member's administered Pages (organizationAcls). No `openid`: the Community
47+
* Management app has no Sign-In product to grant it.
48+
*
49+
* @var list<string>
50+
*/
51+
private const array SCOPES = ['r_organization_social', 'w_organization_social', 'rw_organization_admin'];
52+
53+
public function __construct(
54+
private readonly HttpFactory $http,
55+
private readonly LinkedInOrganizationDiscovery $organizations,
56+
private readonly InstanceSettings $settings,
57+
) {}
58+
59+
/**
60+
* Whether the dedicated Community Management app credentials are configured.
61+
*/
62+
public static function pagesAppConfigured(): bool
63+
{
64+
return filled(config('services.linkedin-pages.client_id'))
65+
&& filled(config('services.linkedin-pages.client_secret'));
66+
}
67+
68+
public function redirect(Request $request): SymfonyRedirectResponse
69+
{
70+
$this->abortUnlessAvailable($request);
71+
72+
// Stateless, like MetaConnectionController: the `auth` middleware plus the
73+
// explicit POST on the picker are the CSRF gates. `state` is echoed for
74+
// OAuth hygiene but not verified, since session state is unreliable behind
75+
// TLS-terminating proxies/tunnels.
76+
$query = http_build_query([
77+
'response_type' => 'code',
78+
'client_id' => (string) config('services.linkedin-pages.client_id'),
79+
'redirect_uri' => route('accounts.linkedin-pages.callback'),
80+
'state' => Str::random(40),
81+
'scope' => implode(' ', self::SCOPES),
82+
]);
83+
84+
return redirect()->away(self::AUTHORIZE_URL.'?'.$query);
85+
}
86+
87+
public function callback(Request $request): RedirectResponse|InertiaResponse
88+
{
89+
$this->abortUnlessAvailable($request);
90+
91+
if ($request->filled('error')) {
92+
Log::warning('LinkedIn Pages OAuth provider returned an error.', [
93+
'error' => $request->query('error'),
94+
'error_description' => $request->query('error_description'),
95+
]);
96+
97+
return $this->failed($request->query('error') === 'user_cancelled_authorize'
98+
? 'You declined to connect your LinkedIn Page.'
99+
: "LinkedIn couldn't complete the connection. Please try again.");
100+
}
101+
102+
if (! $request->filled('code')) {
103+
return $this->failed("We couldn't connect your LinkedIn Page. Please try again.");
104+
}
105+
106+
$token = $this->exchangeCodeForToken((string) $request->query('code'));
107+
108+
if ($token === null) {
109+
return $this->failed("We couldn't connect your LinkedIn Page. Please try again.");
110+
}
111+
112+
try {
113+
$organizations = $this->organizations->administeredOrganizations($token['accessToken']);
114+
} catch (LinkedInOrganizationDiscoveryException $e) {
115+
Log::warning('LinkedIn Pages discovery failed.', ['status' => $e->getCode(), 'error' => $e->getMessage()]);
116+
117+
return $this->failed(match (true) {
118+
$e->isRateLimited() => "LinkedIn is rate-limiting this app, so we couldn't list your Pages. "
119+
.'Please try again later.',
120+
$e->isForbidden() => 'LinkedIn denied access to your Pages. Ask your instance admin to check that the LinkedIn Pages app '
121+
.'has Community Management API access.',
122+
default => "LinkedIn couldn't list your Pages right now. Please try again.",
123+
});
124+
}
125+
126+
if ($organizations === []) {
127+
return $this->failed(
128+
"We couldn't find any LinkedIn Pages you administer. Only Pages where you're an "
129+
.'admin can be connected — check your role on the Page, then try again.',
130+
);
131+
}
132+
133+
$stashedOrganizations = [];
134+
foreach ($organizations as $organization) {
135+
$stashedOrganizations[$organization->id] = [
136+
'id' => $organization->id,
137+
'urn' => $organization->urn,
138+
'name' => $organization->name,
139+
'vanityName' => $organization->vanityName,
140+
];
141+
}
142+
143+
$request->session()->put(self::SESSION_KEY, [
144+
'organizations' => $stashedOrganizations,
145+
'accessToken' => $token['accessToken'],
146+
'refreshToken' => $token['refreshToken'],
147+
'tokenExpiresAt' => $token['expiresAt']?->toIso8601String(),
148+
'approvedScopes' => $token['scopes'],
149+
]);
150+
151+
return Inertia::render('accounts/connect-linkedin', [
152+
'organizations' => array_values($stashedOrganizations),
153+
]);
154+
}
155+
156+
/**
157+
* Exchange the authorization code for a member token using the Community
158+
* Management app's credentials. Returns null on any failure (logged).
159+
*
160+
* @return array{accessToken: string, refreshToken: ?string, expiresAt: ?CarbonImmutable, scopes: list<string>}|null
161+
*/
162+
private function exchangeCodeForToken(string $code): ?array
163+
{
164+
$response = $this->http->asForm()->post(self::TOKEN_URL, [
165+
'grant_type' => 'authorization_code',
166+
'code' => $code,
167+
'redirect_uri' => route('accounts.linkedin-pages.callback'),
168+
'client_id' => (string) config('services.linkedin-pages.client_id'),
169+
'client_secret' => (string) config('services.linkedin-pages.client_secret'),
170+
]);
171+
172+
if ($response->failed()) {
173+
Log::warning('LinkedIn Pages token exchange failed.', [
174+
'status' => $response->status(),
175+
'error' => $response->json('error'),
176+
'error_description' => $response->json('error_description'),
177+
]);
178+
179+
return null;
180+
}
181+
182+
$accessToken = (string) $response->json('access_token');
183+
184+
if ($accessToken === '') {
185+
return null;
186+
}
187+
188+
$expiresIn = (int) $response->json('expires_in');
189+
$refreshToken = $response->json('refresh_token');
190+
191+
return [
192+
'accessToken' => $accessToken,
193+
'refreshToken' => is_string($refreshToken) && $refreshToken !== '' ? $refreshToken : null,
194+
'expiresAt' => $expiresIn > 0 ? Date::now()->addSeconds($expiresIn)->toImmutable() : null,
195+
'scopes' => $this->parseScopes($response->json('scope')),
196+
];
197+
}
198+
199+
/**
200+
* LinkedIn returns granted scopes as a comma- or space-separated string.
201+
*
202+
* @return list<string>
203+
*/
204+
private function parseScopes(mixed $scope): array
205+
{
206+
if (! is_string($scope) || $scope === '') {
207+
return [];
208+
}
209+
210+
return array_values(array_filter(preg_split('/[\s,]+/', $scope) ?: []));
211+
}
212+
213+
/**
214+
* The Pages flow is only reachable when the operator has both turned on the
215+
* Community Management ("LinkedIn Pages") toggle and configured the dedicated
216+
* app. Anything else 404s — there's no personal-account fallback here.
217+
*/
218+
private function abortUnlessAvailable(Request $request): void
219+
{
220+
$request->user()->can('create', ConnectedAccount::class) ?: abort(403);
221+
222+
if (! $this->settings->linkedinCommunityManagementEnabled() || ! self::pagesAppConfigured()) {
223+
abort(404);
224+
}
225+
}
226+
227+
private function failed(string $message): RedirectResponse
228+
{
229+
return redirect()->route('accounts.index')->with('error', $message);
230+
}
231+
}

0 commit comments

Comments
 (0)