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