├── .gitignore ├── .scrutinizer.yml ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Provider │ ├── Exception │ │ └── LinkedInAccessDeniedException.php │ ├── LinkedIn.php │ └── LinkedInResourceOwner.php └── Token │ └── LinkedInAccessToken.php └── test ├── api_responses ├── email.json └── me.json └── src └── Provider └── LinkedInTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /vendor 3 | composer.phar 4 | composer.lock 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | filter: 2 | excluded_paths: [test/*] 3 | checks: 4 | php: 5 | code_rating: true 6 | remove_extra_empty_lines: true 7 | remove_php_closing_tag: true 8 | remove_trailing_whitespace: true 9 | fix_use_statements: 10 | remove_unused: true 11 | preserve_multiple: false 12 | preserve_blanklines: true 13 | order_alphabetically: true 14 | fix_php_opening_tag: true 15 | fix_linefeed: true 16 | fix_line_ending: true 17 | fix_identation_4spaces: true 18 | fix_doc_comments: true 19 | tools: 20 | external_code_coverage: 21 | timeout: 600 22 | runs: 1 23 | php_analyzer: true 24 | php_code_coverage: false 25 | php_code_sniffer: 26 | config: 27 | standard: PSR2 28 | filter: 29 | paths: ['src'] 30 | php_loc: 31 | enabled: true 32 | excluded_dirs: [vendor, test] 33 | php_cpd: 34 | enabled: true 35 | excluded_dirs: [vendor, test] 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | matrix: 4 | include: 5 | - php: 5.6 6 | - php: 7.0 7 | - php: 7.1 8 | - php: 7.2 9 | - php: nightly 10 | - php: hhvm-3.6 11 | sudo: required 12 | dist: trusty 13 | group: edge 14 | - php: hhvm-3.9 15 | sudo: required 16 | dist: trusty 17 | group: edge 18 | - php: hhvm-3.12 19 | sudo: required 20 | dist: trusty 21 | group: edge 22 | - php: hhvm-3.15 23 | sudo: required 24 | dist: trusty 25 | group: edge 26 | - php: hhvm-nightly 27 | sudo: required 28 | dist: trusty 29 | group: edge 30 | fast_finish: true 31 | allow_failures: 32 | - php: nightly 33 | - php: hhvm-3.6 34 | - php: hhvm-3.9 35 | - php: hhvm-3.12 36 | - php: hhvm-3.15 37 | - php: hhvm-nightly 38 | 39 | before_script: 40 | - travis_retry composer self-update 41 | - travis_retry composer install --no-interaction --prefer-source --dev 42 | - travis_retry phpenv rehash 43 | 44 | script: 45 | - ./vendor/bin/phpcs --standard=psr2 src/ 46 | - ./vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover 47 | 48 | after_script: 49 | - if [ "$TRAVIS_PHP_VERSION" == "7.1" ]; then wget https://scrutinizer-ci.com/ocular.phar; fi 50 | - if [ "$TRAVIS_PHP_VERSION" == "7.1" ]; then php ocular.phar code-coverage:upload --format=php-clover coverage.clover; fi 51 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All Notable changes to `oauth2-linkedin` will be documented in this file 3 | 4 | ## 5.1.2 - 2020-04-20 5 | 6 | ### Added 7 | - Getting resource owner data as an array now includes the email, if provided. - thanks @vyskocilpavel. 8 | 9 | ### Deprecated 10 | - Nothing 11 | 12 | ### Fixed 13 | - Nothing 14 | 15 | ### Removed 16 | - Nothing 17 | 18 | ### Security 19 | - Nothing 20 | 21 | ## 5.1.1 - 2019-11-11 22 | 23 | ### Added 24 | - Improved null checking when checking error responses - thanks @Addvilz. 25 | 26 | ### Deprecated 27 | - Nothing 28 | 29 | ### Fixed 30 | - Nothing 31 | 32 | ### Removed 33 | - Nothing 34 | 35 | ### Security 36 | - Nothing 37 | 38 | ## 5.1.0 - 2019-05-22 39 | 40 | ### Added 41 | - Support for attempting to obtain email address when fetching resource owner. - thanks @OJezu 42 | 43 | ### Deprecated 44 | - Nothing 45 | 46 | ### Fixed 47 | - Nothing 48 | 49 | ### Removed 50 | - Nothing 51 | 52 | ### Security 53 | - Nothing 54 | 55 | ## 5.0.1 - 2019-05-11 56 | 57 | ### Added 58 | - Support for obtaining refresh tokens and their expiration timestamp. 59 | 60 | ### Deprecated 61 | - Nothing 62 | 63 | ### Fixed 64 | - Nothing 65 | 66 | ### Removed 67 | - Nothing 68 | 69 | ### Security 70 | - Nothing 71 | 72 | ## 5.0.0 - 2019-05-11 73 | 74 | ### Added 75 | - Support for obtaining the resource owner email address via second method. - thanks @pajavyskocil and @OJezu 76 | 77 | ### Deprecated 78 | - Support for LinkedIn API Version 1 79 | 80 | ### Fixed 81 | - Nothing 82 | 83 | ### Removed 84 | - Nothing 85 | 86 | ### Security 87 | - Nothing 88 | 89 | ## 4.1.1 - 2018-07-23 90 | 91 | ### Added 92 | - Nothing 93 | 94 | ### Deprecated 95 | - Nothing 96 | 97 | ### Fixed 98 | - Resolved problem retrieving user detail fields from v1 API - thanks @pwweb and @Akimkin 99 | 100 | ### Removed 101 | - Nothing 102 | 103 | ### Security 104 | - Nothing 105 | 106 | ## 4.1.0 - 2018-06-21 107 | 108 | ### Added 109 | - Add configurable resource owner endpoint version. 110 | 111 | ### Deprecated 112 | - Nothing 113 | 114 | ### Fixed 115 | - Nothing 116 | 117 | ### Removed 118 | - Nothing 119 | 120 | ### Security 121 | - Nothing 122 | 123 | ## 4.0.0 - 2018-06-21 124 | 125 | ### Added 126 | - Update resource owner url to use v2 of API. 127 | 128 | ### Deprecated 129 | - Nothing 130 | 131 | ### Fixed 132 | - Nothing 133 | 134 | ### Removed 135 | - Nothing 136 | 137 | ### Security 138 | - Nothing 139 | 140 | ## 3.1.0 - 2018-05-04 141 | 142 | ### Added 143 | - Add a summary field from LinkedIn API - thanks @krainiuk-michael 144 | 145 | ### Deprecated 146 | - Nothing 147 | 148 | ### Fixed 149 | - Nothing 150 | 151 | ### Removed 152 | - Nothing 153 | 154 | ### Security 155 | - Nothing 156 | 157 | ## 3.0.0 - 2018-03-17 158 | 159 | ### Added 160 | - Explicit support for resource owner fields definition 161 | - Support for accessing resource owner details using dot notation 162 | 163 | ### Deprecated 164 | - Nothing 165 | 166 | ### Fixed 167 | - Nothing 168 | 169 | ### Removed 170 | - Public access to $fields property on League\OAuth2\Client\Provider\LinkedIn instances 171 | 172 | ### Security 173 | - Nothing 174 | 175 | ## 2.1.0 - 2017-09-11 176 | 177 | ### Added 178 | - Updated authorization urls - thanks @iisisrael 179 | 180 | ### Deprecated 181 | - Nothing 182 | 183 | ### Fixed 184 | - Nothing 185 | 186 | ### Removed 187 | - Nothing 188 | 189 | ### Security 190 | - Nothing 191 | 192 | ## 2.0.0 - 2017-01-25 193 | 194 | ### Added 195 | - PHP 7.1 Support 196 | 197 | ### Deprecated 198 | - Nothing 199 | 200 | ### Fixed 201 | - Nothing 202 | 203 | ### Removed 204 | - PHP 5.5 Support 205 | 206 | ### Security 207 | - Nothing 208 | 209 | ## 1.0.0 - 2017-01-25 210 | 211 | Bump for base package parity 212 | 213 | ## 0.4.2 - 2016-11-09 214 | 215 | ### Added 216 | - Nothing 217 | 218 | ### Deprecated 219 | - Nothing 220 | 221 | ### Fixed 222 | - Check if index is set in response during method call 223 | 224 | ### Removed 225 | - Nothing 226 | 227 | ### Security 228 | - Nothing 229 | 230 | ## 0.4.1 - 2016-08-06 231 | 232 | ### Added 233 | - Update name of resource owner methods to follow "convention". 234 | 235 | ### Deprecated 236 | - Nothing 237 | 238 | ### Fixed 239 | - Nothing 240 | 241 | ### Removed 242 | - Nothing 243 | 244 | ### Security 245 | - Nothing 246 | 247 | ## 0.4.0 - 2015-08-20 248 | 249 | ### Added 250 | - Upgrade to support version 1.0 release of core client 251 | 252 | ### Deprecated 253 | - Nothing 254 | 255 | ### Fixed 256 | - Nothing 257 | 258 | ### Removed 259 | - Nothing 260 | 261 | ### Security 262 | - Nothing 263 | 264 | ## 0.3.0 - 2015-06-11 265 | 266 | ### Added 267 | - Array defined scope definition 268 | 269 | ### Deprecated 270 | - Nothing 271 | 272 | ### Fixed 273 | - Using abstract provider scope separator to format scopes 274 | 275 | ## 0.2.0 - 2015-05-26 276 | 277 | ### Added 278 | - Depends on "league/oauth2-client": "0.10.*@dev" 279 | 280 | ### Deprecated 281 | - Default scopes in provider; now requires explicit declaration by consuming applications. 282 | 283 | ### Fixed 284 | - Nothing 285 | 286 | ### Removed 287 | - Nothing 288 | 289 | ### Security 290 | - Nothing 291 | 292 | ## 0.1.1 - 2015-03-23 293 | 294 | ### Added 295 | - Nothing 296 | 297 | ### Deprecated 298 | - Nothing 299 | 300 | ### Fixed 301 | - Namespace issue 302 | 303 | ### Removed 304 | - Nothing 305 | 306 | ### Security 307 | - Nothing 308 | 309 | ## 0.1.0 - 2015-03-21 310 | 311 | ### Added 312 | - Initial release! 313 | 314 | ### Deprecated 315 | - Nothing 316 | 317 | ### Fixed 318 | - Nothing 319 | 320 | ### Removed 321 | - Nothing 322 | 323 | ### Security 324 | - Nothing 325 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/thephpleague/oauth2-linkedin). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the README and any other relevant documentation are kept up-to-date. 15 | 16 | - **Consider our release cycle** - We try to follow SemVer. Randomly breaking public APIs is not an option. 17 | 18 | - **Create topic branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please squash them before submitting. 23 | 24 | - **Ensure tests pass!** - Please run the tests (see below) before submitting your pull request, and make sure they pass. We won't accept a patch until all tests pass. 25 | 26 | - **Ensure no coding standards violations** - Please run PHP Code Sniffer using the PSR-2 standard (see below) before submitting your pull request. A violation will cause the build to fail, so please make sure there are no violations. We can't accept a patch if the build fails. 27 | 28 | 29 | ## Running Tests 30 | 31 | ``` bash 32 | $ ./vendor/bin/phpunit 33 | ``` 34 | 35 | 36 | ## Running PHP Code Sniffer 37 | 38 | ``` bash 39 | $ ./vendor/bin/phpcs src --standard=psr2 -sp 40 | ``` 41 | 42 | **Happy coding**! 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Steven Maguire 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LinkedIn Provider for OAuth 2.0 Client 2 | [![Latest Version](https://img.shields.io/github/release/thephpleague/oauth2-linkedin.svg?style=flat-square)](https://github.com/thephpleague/oauth2-linkedin/releases) 3 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 4 | [![Build Status](https://img.shields.io/travis/thephpleague/oauth2-linkedin/master.svg?style=flat-square)](https://travis-ci.org/thephpleague/oauth2-linkedin) 5 | [![Coverage Status](https://img.shields.io/scrutinizer/coverage/g/thephpleague/oauth2-linkedin.svg?style=flat-square)](https://scrutinizer-ci.com/g/thephpleague/oauth2-linkedin/code-structure) 6 | [![Quality Score](https://img.shields.io/scrutinizer/g/thephpleague/oauth2-linkedin.svg?style=flat-square)](https://scrutinizer-ci.com/g/thephpleague/oauth2-linkedin) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/league/oauth2-linkedin.svg?style=flat-square)](https://packagist.org/packages/league/oauth2-linkedin) 8 | 9 | This package provides LinkedIn OAuth 2.0 support for the PHP League's [OAuth 2.0 Client](https://github.com/thephpleague/oauth2-client). 10 | 11 | ## Before You Begin 12 | 13 | > The LinkedIn API has been largely closed off and is only available to approved LinkedIn developers. You can request authorization here - [https://business.linkedin.com/marketing-solutions/marketing-partners/become-a-partner/marketing-developer-program](https://business.linkedin.com/marketing-solutions/marketing-partners/become-a-partner/marketing-developer-program) 14 | 15 | You may be able to successfully obtain Access Tokens using this package and still not be authorized to access some resources available in the API. 16 | 17 | If you encounter the following, or something similar, this policy is being enforced. 18 | 19 | ``` 20 | { 21 | "serviceErrorCode": 100, 22 | "message": "Not enough permissions to access: GET /me", 23 | "status": 403 24 | } 25 | ``` 26 | 27 | ## Installation 28 | 29 | To install, use composer: 30 | 31 | ``` 32 | composer require league/oauth2-linkedin 33 | ``` 34 | 35 | ## Usage 36 | 37 | Usage is the same as The League's OAuth client, using `\League\OAuth2\Client\Provider\LinkedIn` as the provider. 38 | 39 | ### Authorization Code Flow 40 | 41 | ```php 42 | $provider = new League\OAuth2\Client\Provider\LinkedIn([ 43 | 'clientId' => '{linkedin-client-id}', 44 | 'clientSecret' => '{linkedin-client-secret}', 45 | 'redirectUri' => 'https://example.com/callback-url', 46 | ]); 47 | 48 | if (!isset($_GET['code'])) { 49 | 50 | // If we don't have an authorization code then get one 51 | $authUrl = $provider->getAuthorizationUrl(); 52 | $_SESSION['oauth2state'] = $provider->getState(); 53 | header('Location: '.$authUrl); 54 | exit; 55 | 56 | // Check given state against previously stored one to mitigate CSRF attack 57 | } elseif (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) { 58 | 59 | unset($_SESSION['oauth2state']); 60 | exit('Invalid state'); 61 | 62 | } else { 63 | 64 | // Try to get an access token (using the authorization code grant) 65 | $token = $provider->getAccessToken('authorization_code', [ 66 | 'code' => $_GET['code'] 67 | ]); 68 | 69 | // Optional: Now you have a token you can look up a users profile data 70 | try { 71 | 72 | // We got an access token, let's now get the user's details 73 | $user = $provider->getResourceOwner($token); 74 | 75 | // Use these details to create a new profile 76 | printf('Hello %s!', $user->getFirstName()); 77 | 78 | } catch (Exception $e) { 79 | 80 | // Failed to get user details 81 | exit('Oh dear...'); 82 | } 83 | 84 | // Use this to interact with an API on the users behalf 85 | echo $token->getToken(); 86 | } 87 | ``` 88 | 89 | ### Managing Scopes 90 | 91 | When creating your LinkedIn authorization URL, you can specify the state and scopes your application may authorize. 92 | 93 | ```php 94 | $options = [ 95 | 'state' => 'OPTIONAL_CUSTOM_CONFIGURED_STATE', 96 | 'scope' => ['r_liteprofile','r_emailaddress'] // array or string 97 | ]; 98 | 99 | $authorizationUrl = $provider->getAuthorizationUrl($options); 100 | ``` 101 | If neither are defined, the provider will utilize internal defaults. 102 | 103 | At the time of authoring this documentation, the following scopes are available. 104 | 105 | - r_liteprofile (requested by default) 106 | - r_emailaddress (requested by default) 107 | - r_fullprofile 108 | - w_member_social 109 | - rw_company_admin 110 | 111 | ### Retrieving LinkedIn member information 112 | 113 | When fetching resource owner details, the provider allows for an explicit list of fields to be returned, so long as they are allowed by the scopes used to retrieve the access token. 114 | 115 | A default set of fields is provided. Overriding these defaults and defining a new set of fields is easy using the `withFields` method, which is a fluent method that returns the updated provider. 116 | 117 | You can find a complete list of fields on LinkedIn's Developer Documentation: 118 | - [For r_liteprofile](https://docs.microsoft.com/en-us/linkedin/shared/references/v2/profile/basic-profile). 119 | - [For r_fullprofile](https://docs.microsoft.com/en-us/linkedin/shared/references/v2/profile/full-profile). 120 | 121 | ```php 122 | $fields = [ 123 | 'id', 'firstName', 'lastName', 'maidenName', 124 | 'headline', 'vanityName', 'birthDate', 'educations' 125 | ]; 126 | 127 | $provider = $provider->withFields($fields); 128 | $member = $provider->getResourceOwner($token); 129 | 130 | // or in one line... 131 | 132 | $member = $provider->withFields($fields)->getResourceOwner($token); 133 | ``` 134 | 135 | The `getResourceOwner` will return an instance of `League\OAuth2\Client\Provider\LinkedInResourceOwner` which has some helpful getter methods to access basic member details. 136 | 137 | For more customization and control, the `LinkedInResourceOwner` object also offers a `getAttribute` method which accepts a string to access specific attributes that may not have a getter method explicitly defined. 138 | 139 | ```php 140 | $firstName = $member->getFirstName(); 141 | $birthDate = $member->getAttribute('birthDate'); 142 | ``` 143 | 144 | #### A note about obtaining the resource owner's email address 145 | 146 | > The email has to be fetched by the provider in a separate request, it is not one of the profile fields. 147 | 148 | When getting the resource owner a second request to fetch the email address will always be attempted. This request will fail silently (and `getEmail()` will return `null`) if the access token provided was not issued with the `r_emailaddress` scope. 149 | 150 | ```php 151 | $member = $provider->getResourceOwner($token); 152 | $email = $member->getEmail(); 153 | ``` 154 | 155 | You can also attempt to fetch the email in a separate request. This request will fail and throw an exception if the access token provided was not issued with the `r_emailaddress` scope. 156 | 157 | ```php 158 | $emailAddress = $provider->getResourceOwnerEmail($token); 159 | 160 | ``` 161 | 162 | 163 | 164 | ### Refresh Tokens 165 | 166 | > LinkedIn has introduced Refresh Tokens with OAuth 2.0. This feature is currently available for a limited set of partners. It will be made GA in the near future. [Source](https://developer.linkedin.com/docs/Refresh-Tokens-with-OAuth-2) 167 | 168 | If your LinkedIn Client ID is associated with a partner that supports refresh tokens, this package will help you access and work with Refresh Tokens. 169 | 170 | ``` 171 | $refreshToken = $token->getRefreshToken(); 172 | $refreshTokenExpiration = $token->getRefreshTokenExpires(); 173 | ``` 174 | 175 | ## Testing 176 | 177 | ``` bash 178 | $ ./vendor/bin/phpunit 179 | ``` 180 | 181 | ## Contributing 182 | 183 | Please see [CONTRIBUTING](https://github.com/thephpleague/oauth2-linkedin/blob/master/CONTRIBUTING.md) for details. 184 | 185 | 186 | ## Credits 187 | 188 | - [Steven Maguire](https://github.com/stevenmaguire) 189 | - [All Contributors](https://github.com/thephpleague/oauth2-linkedin/contributors) 190 | 191 | 192 | ## License 193 | 194 | The MIT License (MIT). Please see [License File](https://github.com/thephpleague/oauth2-linkedin/blob/master/LICENSE) for more information. 195 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "league/oauth2-linkedin", 3 | "description": "LinkedIn OAuth 2.0 Client Provider for The PHP League OAuth2-Client", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Steven Maguire", 8 | "email": "stevenmaguire@gmail.com", 9 | "homepage": "https://github.com/stevenmaguire" 10 | } 11 | ], 12 | "keywords": [ 13 | "oauth", 14 | "oauth2", 15 | "client", 16 | "authorization", 17 | "authorisation", 18 | "linkedin" 19 | ], 20 | "require": { 21 | "league/oauth2-client": "^2.0" 22 | }, 23 | "require-dev": { 24 | "phpunit/phpunit": "~4.0", 25 | "mockery/mockery": "~0.9", 26 | "squizlabs/php_codesniffer": "~2.0", 27 | "ext-json": "*" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "League\\OAuth2\\Client\\": "src/" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "League\\OAuth2\\Client\\Test\\": "test/src/" 37 | } 38 | }, 39 | "extra": { 40 | "branch-alias": { 41 | "dev-master": "1.0.x-dev" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 20 | 22 | 23 | 24 | 25 | ./test/ 26 | 27 | 28 | 29 | 30 | ./ 31 | 32 | ./vendor 33 | ./test 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/Provider/Exception/LinkedInAccessDeniedException.php: -------------------------------------------------------------------------------- 1 | '(' . implode(',', $this->fields) . ')' 114 | ]); 115 | 116 | return 'https://api.linkedin.com/v2/me?' . urldecode($query); 117 | } 118 | 119 | /** 120 | * Get provider url to fetch user details 121 | * 122 | * @param AccessToken $token 123 | * 124 | * @return string 125 | */ 126 | public function getResourceOwnerEmailUrl(AccessToken $token) 127 | { 128 | $query = http_build_query([ 129 | 'q' => 'members', 130 | 'projection' => '(elements*(state,primary,type,handle~))' 131 | ]); 132 | 133 | return 'https://api.linkedin.com/v2/clientAwareMemberHandles?' . urldecode($query); 134 | } 135 | 136 | /** 137 | * Get the default scopes used by this provider. 138 | * 139 | * This should not be a complete list of all scopes, but the minimum 140 | * required for the provider user interface! 141 | * 142 | * @return array 143 | */ 144 | protected function getDefaultScopes() 145 | { 146 | return $this->defaultScopes; 147 | } 148 | 149 | /** 150 | * Check a provider response for errors. 151 | * 152 | * @param ResponseInterface $response 153 | * @param array $data Parsed response data 154 | * @return void 155 | * @throws IdentityProviderException 156 | * @see https://developer.linkedin.com/docs/guide/v2/error-handling 157 | */ 158 | protected function checkResponse(ResponseInterface $response, $data) 159 | { 160 | $this->checkResponseUnauthorized($response, $data); 161 | 162 | if ($response->getStatusCode() >= 400) { 163 | throw new IdentityProviderException( 164 | isset($data['message']) ? $data['message'] : $response->getReasonPhrase(), 165 | isset($data['status']) ? $data['status'] : $response->getStatusCode(), 166 | $response 167 | ); 168 | } 169 | } 170 | 171 | /** 172 | * Check a provider response for unauthorized errors. 173 | * 174 | * @param ResponseInterface $response 175 | * @param array $data Parsed response data 176 | * @return void 177 | * @throws LinkedInAccessDeniedException 178 | * @see https://developer.linkedin.com/docs/guide/v2/error-handling 179 | */ 180 | protected function checkResponseUnauthorized(ResponseInterface $response, $data) 181 | { 182 | if (isset($data['status']) && $data['status'] === 403) { 183 | throw new LinkedInAccessDeniedException( 184 | isset($data['message']) ? $data['message'] : $response->getReasonPhrase(), 185 | isset($data['status']) ? $data['status'] : $response->getStatusCode(), 186 | $response 187 | ); 188 | } 189 | } 190 | 191 | /** 192 | * Generate a user object from a successful user details request. 193 | * 194 | * @param array $response 195 | * @param AccessToken $token 196 | * @return LinkedInResourceOwner 197 | */ 198 | protected function createResourceOwner(array $response, AccessToken $token) 199 | { 200 | // If current accessToken is not authorized with r_emailaddress scope, 201 | // getResourceOwnerEmail will throw LinkedInAccessDeniedException, it will be caught here, 202 | // and then the email will be set to null 203 | // When email is not available due to chosen scopes, other providers simply set it to null, let's do the same. 204 | try { 205 | $email = $this->getResourceOwnerEmail($token); 206 | } catch (LinkedInAccessDeniedException $exception) { 207 | $email = null; 208 | } 209 | $response['email'] = $email; 210 | return new LinkedInResourceOwner($response); 211 | } 212 | 213 | /** 214 | * Returns the requested fields in scope. 215 | * 216 | * @return array 217 | */ 218 | public function getFields() 219 | { 220 | return $this->fields; 221 | } 222 | 223 | /** 224 | * Attempts to fetch resource owner's email address via separate API request. 225 | * 226 | * @param AccessToken $token [description] 227 | * @return string|null 228 | * @throws IdentityProviderException 229 | */ 230 | public function getResourceOwnerEmail(AccessToken $token) 231 | { 232 | $emailUrl = $this->getResourceOwnerEmailUrl($token); 233 | $emailRequest = $this->getAuthenticatedRequest(self::METHOD_GET, $emailUrl, $token); 234 | $emailResponse = $this->getParsedResponse($emailRequest); 235 | 236 | return $this->extractEmailFromResponse($emailResponse); 237 | } 238 | 239 | /** 240 | * Updates the requested fields in scope. 241 | * 242 | * @param array $fields 243 | * 244 | * @return LinkedIn 245 | */ 246 | public function withFields(array $fields) 247 | { 248 | $this->fields = $fields; 249 | 250 | return $this; 251 | } 252 | 253 | /** 254 | * Attempts to extract the email address from a valid email api response. 255 | * 256 | * @param array $response 257 | * @return string|null 258 | */ 259 | protected function extractEmailFromResponse($response = []) 260 | { 261 | try { 262 | $confirmedEmails = array_filter($response['elements'], function ($element) { 263 | return 264 | strtoupper($element['type']) === 'EMAIL' 265 | && strtoupper($element['state']) === 'CONFIRMED' 266 | && $element['primary'] === true 267 | && isset($element['handle~']['emailAddress']) 268 | ; 269 | }); 270 | 271 | return $confirmedEmails[0]['handle~']['emailAddress']; 272 | } catch (Exception $e) { 273 | return null; 274 | } 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/Provider/LinkedInResourceOwner.php: -------------------------------------------------------------------------------- 1 | response = $response; 41 | $this->setSortedProfilePictures(); 42 | } 43 | 44 | /** 45 | * Gets resource owner attribute by key. The key supports dot notation. 46 | * 47 | * @return mixed 48 | */ 49 | public function getAttribute($key) 50 | { 51 | return $this->getValueByKey($this->response, (string) $key); 52 | } 53 | 54 | /** 55 | * Get user first name 56 | * 57 | * @return string|null 58 | */ 59 | public function getFirstName() 60 | { 61 | return $this->getAttribute('localizedFirstName'); 62 | } 63 | 64 | /** 65 | * Get user user id 66 | * 67 | * @return string|null 68 | */ 69 | public function getId() 70 | { 71 | return $this->getAttribute('id'); 72 | } 73 | 74 | /** 75 | * Get specific image by size 76 | * 77 | * @param integer $size 78 | * @return array|null 79 | */ 80 | public function getImageBySize($size) 81 | { 82 | $pictures = array_filter($this->sortedProfilePictures, function ($picture) use ($size) { 83 | return isset($picture['width']) && $picture['width'] == $size; 84 | }); 85 | 86 | return count($pictures) ? $pictures[0] : null; 87 | } 88 | 89 | /** 90 | * Get available user image sizes 91 | * 92 | * @return array 93 | */ 94 | public function getImageSizes() 95 | { 96 | return array_map(function ($picture) { 97 | return $this->getValueByKey($picture, 'width'); 98 | }, $this->sortedProfilePictures); 99 | } 100 | 101 | /** 102 | * Get user image url 103 | * 104 | * @return string|null 105 | */ 106 | public function getImageUrl() 107 | { 108 | $pictures = $this->getSortedProfilePictures(); 109 | $picture = array_pop($pictures); 110 | 111 | return $picture ? $this->getValueByKey($picture, 'url') : null; 112 | } 113 | 114 | /** 115 | * Get user last name 116 | * 117 | * @return string|null 118 | */ 119 | public function getLastName() 120 | { 121 | return $this->getAttribute('localizedLastName'); 122 | } 123 | 124 | /** 125 | * Returns the sorted collection of profile pictures. 126 | * 127 | * @return array 128 | */ 129 | public function getSortedProfilePictures() 130 | { 131 | return $this->sortedProfilePictures; 132 | } 133 | 134 | /** 135 | * Get user url 136 | * 137 | * @return string|null 138 | */ 139 | public function getUrl() 140 | { 141 | $vanityName = $this->getAttribute('vanityName'); 142 | 143 | return $vanityName ? sprintf('https://www.linkedin.com/in/%s', $vanityName) : null; 144 | } 145 | 146 | /** 147 | * Get user email, if available 148 | * 149 | * @return string|null 150 | */ 151 | public function getEmail() 152 | { 153 | return $this->getAttribute('email'); 154 | } 155 | 156 | /** 157 | * Attempts to sort the collection of profile pictures included in the profile 158 | * before caching them in the resource owner instance. 159 | * 160 | * @return void 161 | */ 162 | private function setSortedProfilePictures() 163 | { 164 | $pictures = $this->getAttribute('profilePicture.displayImage~.elements'); 165 | if (is_array($pictures)) { 166 | $pictures = array_filter($pictures, function ($element) { 167 | // filter to public images only 168 | return 169 | isset($element['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']) 170 | && strtoupper($element['authorizationMethod']) === 'PUBLIC' 171 | && isset($element['identifiers'][0]['identifier']) 172 | ; 173 | }); 174 | // order images by width, LinkedIn profile pictures are always squares, so that should be good enough 175 | usort($pictures, function ($elementA, $elementB) { 176 | $wA = $elementA['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width']; 177 | $wB = $elementB['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width']; 178 | return $wA - $wB; 179 | }); 180 | $pictures = array_map(function ($element) { 181 | // this is an URL, no idea how many of identifiers there can be, so take the first one. 182 | $url = $element['identifiers'][0]['identifier']; 183 | $type = $element['identifiers'][0]['mediaType']; 184 | $width = $element['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width']; 185 | return [ 186 | 'width' => $width, 187 | 'url' => $url, 188 | 'contentType' => $type, 189 | ]; 190 | }, $pictures); 191 | } else { 192 | $pictures = []; 193 | } 194 | 195 | $this->sortedProfilePictures = $pictures; 196 | } 197 | 198 | /** 199 | * Return all of the owner details available as an array. 200 | * 201 | * @return array 202 | */ 203 | public function toArray() 204 | { 205 | return $this->response; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/Token/LinkedInAccessToken.php: -------------------------------------------------------------------------------- 1 | isExpirationTimestamp($expires)) { 26 | $expires += time(); 27 | } 28 | $this->refreshTokenExpires = $expires; 29 | } 30 | } 31 | 32 | /** 33 | * Returns the refresh token expiration timestamp, if defined. 34 | * 35 | * @return integer|null 36 | */ 37 | public function getRefreshTokenExpires() 38 | { 39 | return $this->refreshTokenExpires; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/api_responses/email.json: -------------------------------------------------------------------------------- 1 | { 2 | "elements": [ 3 | { 4 | "handle": "urn:li:emailAddress:", 5 | "state": "CONFIRMED", 6 | "type": "EMAIL", 7 | "handle~": { 8 | "emailAddress": "resource-owner@example.com" 9 | }, 10 | "primary": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /test/api_responses/me.json: -------------------------------------------------------------------------------- 1 | { 2 | "localizedLastName": "Doe", 3 | "profilePicture": { 4 | "displayImage": "urn:li:digitalmediaAsset:", 5 | "displayImage~": { 6 | "elements": [ 7 | { 8 | "artifact": "urn:li:digitalmediaMediaArtifact:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_100_100)", 9 | "authorizationMethod": "PUBLIC", 10 | "data": { 11 | "com.linkedin.digitalmedia.mediaartifact.StillImage": { 12 | "storageSize": { 13 | "width": 100, 14 | "height": 100 15 | }, 16 | "storageAspectRatio": { 17 | "widthAspect": 1.0, 18 | "heightAspect": 1.0, 19 | "formatted": "1.00:1.00" 20 | }, 21 | "mediaType": "image/jpeg", 22 | "rawCodecSpec": { 23 | "name": "jpeg", 24 | "type": "image" 25 | }, 26 | "displaySize": { 27 | "uom": "PX", 28 | "width": 100.0, 29 | "height": 100.0 30 | }, 31 | "displayAspectRatio": { 32 | "widthAspect": 1.0, 33 | "heightAspect": 1.0, 34 | "formatted": "1.00:1.00" 35 | } 36 | } 37 | }, 38 | "identifiers": [ 39 | { 40 | "identifier": "http://example.com/avatar_100_100.jpeg", 41 | "file": "urn:li:digitalmediaFile:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_100_100,0)", 42 | "index": 0, 43 | "mediaType": "image/jpeg", 44 | "identifierType": "EXTERNAL_URL", 45 | "identifierExpiresInSeconds": 1561593600 46 | } 47 | ] 48 | }, 49 | { 50 | "artifact": "urn:li:digitalmediaMediaArtifact:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_200_200)", 51 | "authorizationMethod": "PUBLIC", 52 | "data": { 53 | "com.linkedin.digitalmedia.mediaartifact.StillImage": { 54 | "storageSize": { 55 | "width": 200, 56 | "height": 200 57 | }, 58 | "storageAspectRatio": { 59 | "widthAspect": 1.0, 60 | "heightAspect": 1.0, 61 | "formatted": "1.00:1.00" 62 | }, 63 | "mediaType": "image/jpeg", 64 | "rawCodecSpec": { 65 | "name": "jpeg", 66 | "type": "image" 67 | }, 68 | "displaySize": { 69 | "uom": "PX", 70 | "width": 200.0, 71 | "height": 200.0 72 | }, 73 | "displayAspectRatio": { 74 | "widthAspect": 1.0, 75 | "heightAspect": 1.0, 76 | "formatted": "1.00:1.00" 77 | } 78 | } 79 | }, 80 | "identifiers": [ 81 | { 82 | "identifier": "http://example.com/avatar_200_200.jpeg", 83 | "file": "urn:li:digitalmediaFile:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_200_200,0)", 84 | "index": 0, 85 | "mediaType": "image/jpeg", 86 | "identifierType": "EXTERNAL_URL", 87 | "identifierExpiresInSeconds": 1561593600 88 | } 89 | ] 90 | }, 91 | { 92 | "artifact": "urn:li:digitalmediaMediaArtifact:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_400_400)", 93 | "authorizationMethod": "PUBLIC", 94 | "data": { 95 | "com.linkedin.digitalmedia.mediaartifact.StillImage": { 96 | "storageSize": { 97 | "width": 400, 98 | "height": 400 99 | }, 100 | "storageAspectRatio": { 101 | "widthAspect": 1.0, 102 | "heightAspect": 1.0, 103 | "formatted": "1.00:1.00" 104 | }, 105 | "mediaType": "image/jpeg", 106 | "rawCodecSpec": { 107 | "name": "jpeg", 108 | "type": "image" 109 | }, 110 | "displaySize": { 111 | "uom": "PX", 112 | "width": 400.0, 113 | "height": 400.0 114 | }, 115 | "displayAspectRatio": { 116 | "widthAspect": 1.0, 117 | "heightAspect": 1.0, 118 | "formatted": "1.00:1.00" 119 | } 120 | } 121 | }, 122 | "identifiers": [ 123 | { 124 | "identifier": "http://example.com/avatar_400_400.jpeg", 125 | "file": "urn:li:digitalmediaFile:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_400_400,0)", 126 | "index": 0, 127 | "mediaType": "image/jpeg", 128 | "identifierType": "EXTERNAL_URL", 129 | "identifierExpiresInSeconds": 1561593600 130 | } 131 | ] 132 | }, 133 | { 134 | "artifact": "urn:li:digitalmediaMediaArtifact:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_800_800)", 135 | "authorizationMethod": "PUBLIC", 136 | "data": { 137 | "com.linkedin.digitalmedia.mediaartifact.StillImage": { 138 | "storageSize": { 139 | "width": 800, 140 | "height": 800 141 | }, 142 | "storageAspectRatio": { 143 | "widthAspect": 1.0, 144 | "heightAspect": 1.0, 145 | "formatted": "1.00:1.00" 146 | }, 147 | "mediaType": "image/jpeg", 148 | "rawCodecSpec": { 149 | "name": "jpeg", 150 | "type": "image" 151 | }, 152 | "displaySize": { 153 | "uom": "PX", 154 | "width": 800.0, 155 | "height": 800.0 156 | }, 157 | "displayAspectRatio": { 158 | "widthAspect": 1.0, 159 | "heightAspect": 1.0, 160 | "formatted": "1.00:1.00" 161 | } 162 | } 163 | }, 164 | "identifiers": [ 165 | { 166 | "identifier": "http://example.com/avatar_800_800.jpeg", 167 | "file": "urn:li:digitalmediaFile:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_800_800,0)", 168 | "index": 0, 169 | "mediaType": "image/jpeg", 170 | "identifierType": "EXTERNAL_URL", 171 | "identifierExpiresInSeconds": 1561593600 172 | } 173 | ] 174 | } 175 | ], 176 | "paging": { 177 | "count": 10, 178 | "start": 0, 179 | "links": [] 180 | } 181 | } 182 | }, 183 | "id": "abcdef1234", 184 | "localizedFirstName": "John", 185 | "vanityName": "john-doe" 186 | } 187 | -------------------------------------------------------------------------------- /test/src/Provider/LinkedInTest.php: -------------------------------------------------------------------------------- 1 | provider = new \League\OAuth2\Client\Provider\LinkedIn([ 16 | 'clientId' => 'mock_client_id', 17 | 'clientSecret' => 'mock_secret', 18 | 'redirectUri' => 'none', 19 | ]); 20 | } 21 | 22 | public function tearDown() 23 | { 24 | m::close(); 25 | parent::tearDown(); 26 | } 27 | 28 | public function testAuthorizationUrl() 29 | { 30 | $url = $this->provider->getAuthorizationUrl(); 31 | $uri = parse_url($url); 32 | parse_str($uri['query'], $query); 33 | 34 | $this->assertArrayHasKey('client_id', $query); 35 | $this->assertArrayHasKey('redirect_uri', $query); 36 | $this->assertArrayHasKey('state', $query); 37 | $this->assertArrayHasKey('scope', $query); 38 | $this->assertArrayHasKey('response_type', $query); 39 | $this->assertArrayHasKey('approval_prompt', $query); 40 | $this->assertNotNull($this->provider->getState()); 41 | } 42 | 43 | public function testResourceOwnerDetailsUrl() 44 | { 45 | $accessToken = m::mock('League\OAuth2\Client\Token\AccessToken'); 46 | $expectedFields = $this->provider->getFields(); 47 | $url = $this->provider->getResourceOwnerDetailsUrl($accessToken); 48 | $uri = parse_url($url); 49 | $path = $uri['path']; 50 | $query = explode('=', $uri['query']); 51 | $fields = $query[1]; 52 | $actualFields = explode(',', preg_replace('/^\((.*)\)$/', '\1', $fields)); 53 | $this->assertEquals('/v2/me', $path); 54 | $this->assertEquals('projection', $query[0]); 55 | $this->assertEquals($expectedFields, $actualFields); 56 | } 57 | 58 | public function testResourceOwnerEmailUrl() 59 | { 60 | $accessToken = m::mock('League\OAuth2\Client\Token\AccessToken'); 61 | $expectedFields = $this->provider->getFields(); 62 | $url = $this->provider->getResourceOwnerEmailUrl($accessToken); 63 | $uri = parse_url($url); 64 | parse_str($uri['query'], $query); 65 | $this->assertEquals('/v2/clientAwareMemberHandles', $uri['path']); 66 | $this->assertEquals('(elements*(state,primary,type,handle~))', $query['projection']); 67 | } 68 | 69 | public function testScopes() 70 | { 71 | $scopeSeparator = ' '; 72 | $options = ['scope' => [uniqid(), uniqid()]]; 73 | $query = ['scope' => implode($scopeSeparator, $options['scope'])]; 74 | $url = $this->provider->getAuthorizationUrl($options); 75 | $encodedScope = $this->buildQueryString($query); 76 | $this->assertContains($encodedScope, $url); 77 | } 78 | 79 | public function testFields() 80 | { 81 | $provider = new \League\OAuth2\Client\Provider\LinkedIn([ 82 | 'clientId' => 'mock_client_id', 83 | 'clientSecret' => 'mock_secret', 84 | 'redirectUri' => 'none' 85 | ]); 86 | 87 | $currentFields = $provider->getFields(); 88 | $customFields = [uniqid(), uniqid()]; 89 | 90 | $this->assertTrue(is_array($currentFields)); 91 | $provider->withFields($customFields); 92 | $this->assertEquals($customFields, $provider->getFields()); 93 | } 94 | 95 | public function testNonArrayFieldsDuringInstantiationThrowsException() 96 | { 97 | $this->setExpectedException(InvalidArgumentException::class); 98 | $provider = new \League\OAuth2\Client\Provider\LinkedIn([ 99 | 'clientId' => 'mock_client_id', 100 | 'clientSecret' => 'mock_secret', 101 | 'redirectUri' => 'none', 102 | 'fields' => 'foo' 103 | ]); 104 | } 105 | 106 | public function testGetAuthorizationUrl() 107 | { 108 | $url = $this->provider->getAuthorizationUrl(); 109 | $uri = parse_url($url); 110 | 111 | $this->assertEquals('/oauth/v2/authorization', $uri['path']); 112 | } 113 | 114 | public function testGetBaseAccessTokenUrl() 115 | { 116 | $params = []; 117 | 118 | $url = $this->provider->getBaseAccessTokenUrl($params); 119 | $uri = parse_url($url); 120 | 121 | $this->assertEquals('/oauth/v2/accessToken', $uri['path']); 122 | } 123 | 124 | public function testGetAccessToken() 125 | { 126 | $response = m::mock('Psr\Http\Message\ResponseInterface'); 127 | $response->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600, "refresh_token": "mock_refresh_token", "refresh_token_expires_in": 7200}'); 128 | $response->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 129 | $response->shouldReceive('getStatusCode')->andReturn(200); 130 | 131 | $client = m::mock('GuzzleHttp\ClientInterface'); 132 | $client->shouldReceive('send')->times(1)->andReturn($response); 133 | $this->provider->setHttpClient($client); 134 | 135 | $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 136 | 137 | $this->assertEquals('mock_access_token', $token->getToken()); 138 | $this->assertLessThanOrEqual(time() + 3600, $token->getExpires()); 139 | $this->assertGreaterThanOrEqual(time(), $token->getExpires()); 140 | $this->assertEquals('mock_refresh_token', $token->getRefreshToken()); 141 | $this->assertLessThanOrEqual(time() + 7200, $token->getRefreshTokenExpires()); 142 | $this->assertGreaterThanOrEqual(time(), $token->getRefreshTokenExpires()); 143 | $this->assertNull($token->getResourceOwnerId()); 144 | } 145 | 146 | public function testUserData() 147 | { 148 | $apiProfileResponse = json_decode(file_get_contents(__DIR__.'/../../api_responses/me.json'), true); 149 | $apiEmailResponse = json_decode(file_get_contents(__DIR__.'/../../api_responses/email.json'), true); 150 | $somethingExtra = ['more' => uniqid()]; 151 | $apiProfileResponse['somethingExtra'] = $somethingExtra; 152 | 153 | $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); 154 | $postResponse->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600}'); 155 | $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 156 | $postResponse->shouldReceive('getStatusCode')->andReturn(200); 157 | 158 | $userResponse = m::mock('Psr\Http\Message\ResponseInterface'); 159 | $userResponse->shouldReceive('getBody')->andReturn(json_encode($apiProfileResponse)); 160 | $userResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 161 | $userResponse->shouldReceive('getStatusCode')->andReturn(200); 162 | 163 | $emailResponse = m::mock('Psr\Http\Message\ResponseInterface'); 164 | $emailResponse->shouldReceive('getBody')->andReturn(json_encode($apiEmailResponse)); 165 | $emailResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 166 | $emailResponse->shouldReceive('getStatusCode')->andReturn(200); 167 | 168 | $client = m::mock('GuzzleHttp\ClientInterface'); 169 | $client->shouldReceive('send') 170 | ->times(3) 171 | ->andReturn($postResponse, $userResponse, $emailResponse); 172 | $this->provider->setHttpClient($client); 173 | 174 | $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 175 | $user = $this->provider->getResourceOwner($token); 176 | 177 | $this->assertEquals('abcdef1234', $user->getId()); 178 | $this->assertEquals('abcdef1234', $user->toArray()['id']); 179 | $this->assertEquals('John', $user->getFirstName()); 180 | $this->assertEquals('John', $user->toArray()['localizedFirstName']); 181 | $this->assertEquals('Doe', $user->getLastName()); 182 | $this->assertEquals('Doe', $user->toArray()['localizedLastName']); 183 | $this->assertEquals('http://example.com/avatar_800_800.jpeg', $user->getImageUrl()); 184 | $this->assertEquals('https://www.linkedin.com/in/john-doe', $user->getUrl()); 185 | $this->assertEquals('resource-owner@example.com', $user->getEmail()); 186 | $this->assertEquals($somethingExtra, $user->getAttribute('somethingExtra')); 187 | $this->assertEquals($somethingExtra, $user->toArray()['somethingExtra']); 188 | $this->assertEquals($somethingExtra['more'], $user->getAttribute('somethingExtra.more')); 189 | $this->assertEquals([100, 200, 400, 800], $user->getImageSizes()); 190 | $this->assertTrue(is_array($user->getImageBySize(100))); 191 | $this->assertNull($user->getImageBySize(300)); 192 | } 193 | 194 | public function testMissingUserData() 195 | { 196 | $userId = rand(1000,9999); 197 | $firstName = uniqid(); 198 | $lastName = uniqid(); 199 | $apiProfileResponse = json_decode(file_get_contents(__DIR__.'/../../api_responses/me.json'), true); 200 | $apiEmailResponse = json_decode(file_get_contents(__DIR__.'/../../api_responses/email.json'), true); 201 | $apiProfileResponse['id'] = $userId; 202 | $apiProfileResponse['localizedFirstName'] = $firstName; 203 | $apiProfileResponse['localizedLastName'] = $lastName; 204 | unset($apiProfileResponse['profilePicture']); 205 | unset($apiProfileResponse['vanityName']); 206 | 207 | $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); 208 | $postResponse->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600}'); 209 | $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 210 | $postResponse->shouldReceive('getStatusCode')->andReturn(200); 211 | 212 | $userResponse = m::mock('Psr\Http\Message\ResponseInterface'); 213 | $userResponse->shouldReceive('getBody')->andReturn(json_encode($apiProfileResponse)); 214 | $userResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 215 | $userResponse->shouldReceive('getStatusCode')->andReturn(200); 216 | 217 | $emailResponse = m::mock('Psr\Http\Message\ResponseInterface'); 218 | $emailResponse->shouldReceive('getBody')->andReturn(json_encode($apiEmailResponse)); 219 | $emailResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 220 | $emailResponse->shouldReceive('getStatusCode')->andReturn(200); 221 | 222 | $client = m::mock('GuzzleHttp\ClientInterface'); 223 | $client->shouldReceive('send') 224 | ->times(3) 225 | ->andReturn($postResponse, $userResponse, $emailResponse); 226 | $this->provider->setHttpClient($client); 227 | 228 | $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 229 | $user = $this->provider->getResourceOwner($token); 230 | 231 | $this->assertEquals($userId, $user->getId()); 232 | $this->assertEquals($userId, $user->toArray()['id']); 233 | $this->assertEquals($firstName, $user->getFirstName()); 234 | $this->assertEquals($firstName, $user->toArray()['localizedFirstName']); 235 | $this->assertEquals($lastName, $user->GeTlAsTnAmE()); // https://github.com/thephpleague/oauth2-linkedin/issues/4 236 | $this->assertEquals($lastName, $user->toArray()['localizedLastName']); 237 | $this->assertEquals(null, $user->getImageurl()); 238 | $this->assertEquals(null, $user->getUrl()); 239 | } 240 | 241 | public function testUserEmail() 242 | { 243 | $apiEmailResponse = json_decode(file_get_contents(__DIR__.'/../../api_responses/email.json'), true); 244 | 245 | $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); 246 | $postResponse->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600}'); 247 | $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 248 | $postResponse->shouldReceive('getStatusCode')->andReturn(200); 249 | 250 | $userResponse = m::mock('Psr\Http\Message\ResponseInterface'); 251 | $userResponse->shouldReceive('getBody')->andReturn(json_encode($apiEmailResponse)); 252 | $userResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 253 | $userResponse->shouldReceive('getStatusCode')->andReturn(200); 254 | 255 | $client = m::mock('GuzzleHttp\ClientInterface'); 256 | $client->shouldReceive('send') 257 | ->times(2) 258 | ->andReturn($postResponse, $userResponse); 259 | $this->provider->setHttpClient($client); 260 | 261 | $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 262 | $email = $this->provider->getResourceOwnerEmail($token); 263 | 264 | $this->assertEquals('resource-owner@example.com', $email); 265 | } 266 | 267 | public function testUserEmailNullIfApiResponseInvalid() 268 | { 269 | foreach ([null, []] as $apiEmailResponse) { 270 | $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); 271 | $postResponse->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600}'); 272 | $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 273 | $postResponse->shouldReceive('getStatusCode')->andReturn(200); 274 | 275 | $emailResponse = m::mock('Psr\Http\Message\ResponseInterface'); 276 | $emailResponse->shouldReceive('getBody')->andReturn(json_encode($apiEmailResponse)); 277 | $emailResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 278 | $emailResponse->shouldReceive('getStatusCode')->andReturn(200); 279 | 280 | $client = m::mock('GuzzleHttp\ClientInterface'); 281 | $client->shouldReceive('send') 282 | ->times(2) 283 | ->andReturn($postResponse, $emailResponse); 284 | $this->provider->setHttpClient($client); 285 | 286 | $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 287 | $email = $this->provider->getResourceOwnerEmail($token); 288 | 289 | $this->assertNull($email); 290 | } 291 | } 292 | 293 | public function testResourceOwnerEmailNullWhenNotAuthorized() 294 | { 295 | $apiProfileResponse = json_decode(file_get_contents(__DIR__.'/../../api_responses/me.json'), true); 296 | 297 | $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); 298 | $postResponse->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600}'); 299 | $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 300 | $postResponse->shouldReceive('getStatusCode')->andReturn(200); 301 | 302 | $userResponse = m::mock('Psr\Http\Message\ResponseInterface'); 303 | $userResponse->shouldReceive('getBody')->andReturn(json_encode($apiProfileResponse)); 304 | $userResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 305 | $userResponse->shouldReceive('getStatusCode')->andReturn(200); 306 | 307 | $emailResponse = m::mock('Psr\Http\Message\ResponseInterface'); 308 | $emailResponse->shouldReceive('getBody')->andReturn('{"message": "Not enough permissions to access: GET-members /clientAwareMemberHandles","status":403,"serviceErrorCode":100}'); 309 | $emailResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 310 | $emailResponse->shouldReceive('getStatusCode')->andReturn(403); 311 | 312 | $client = m::mock('GuzzleHttp\ClientInterface'); 313 | $client->shouldReceive('send') 314 | ->times(3) 315 | ->andReturn($postResponse, $userResponse, $emailResponse); 316 | $this->provider->setHttpClient($client); 317 | 318 | $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 319 | 320 | $user = $this->provider->getResourceOwner($token); 321 | 322 | $this->assertNull($user->getEmail()); 323 | 324 | $this->assertEquals('abcdef1234', $user->getId()); 325 | $this->assertEquals('John', $user->getFirstName()); 326 | $this->assertEquals('Doe', $user->getLastName()); 327 | $this->assertEquals('http://example.com/avatar_800_800.jpeg', $user->getImageUrl()); 328 | $this->assertEquals('https://www.linkedin.com/in/john-doe', $user->getUrl()); 329 | } 330 | 331 | public function testExceptionThrownWhenEmailIsNotAuthorizedButRequestedFromAdapter() 332 | { 333 | $errorMessage = 'Not enough permissions to access: GET-members /clientAwareMemberHandles'; 334 | $errorStatus = 403; 335 | $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); 336 | $postResponse->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600}'); 337 | $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 338 | $postResponse->shouldReceive('getStatusCode')->andReturn(200); 339 | 340 | $emailResponse = m::mock('Psr\Http\Message\ResponseInterface'); 341 | $emailResponse->shouldReceive('getBody')->andReturn('{"message": "'.$errorMessage.'","status":'.$errorStatus.',"serviceErrorCode":100}'); 342 | $emailResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 343 | $emailResponse->shouldReceive('getStatusCode')->andReturn(403); 344 | 345 | $client = m::mock('GuzzleHttp\ClientInterface'); 346 | $client->shouldReceive('send') 347 | ->times(2) 348 | ->andReturn($postResponse, $emailResponse); 349 | $this->provider->setHttpClient($client); 350 | 351 | $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 352 | 353 | try { 354 | $this->provider->getResourceOwnerEmail($token); 355 | } catch (\Exception $exception) { 356 | $this->assertInstanceOf( 357 | 'League\OAuth2\Client\Provider\Exception\LinkedInAccessDeniedException', 358 | $exception, 359 | 'An invalid exception was thrown: '.get_class($exception) 360 | ); 361 | $this->assertEquals($exception->getMessage(), $errorMessage); 362 | $this->assertEquals($exception->getCode(), $errorStatus); 363 | return; 364 | } 365 | $this->fail('No exception was thrown'); 366 | } 367 | 368 | public function testExceptionThrownWhenErrorObjectReceived() 369 | { 370 | $errorMessage = uniqid(); 371 | $errorStatus = rand(400,600); 372 | $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); 373 | $postResponse->shouldReceive('getBody')->andReturn('{"message": "'.$errorMessage.'","status": '.$errorStatus.', "serviceErrorCode": 100}'); 374 | $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 375 | $postResponse->shouldReceive('getStatusCode')->andReturn($errorStatus); 376 | 377 | $client = m::mock('GuzzleHttp\ClientInterface'); 378 | $client->shouldReceive('send') 379 | ->times(1) 380 | ->andReturn($postResponse); 381 | $this->provider->setHttpClient($client); 382 | 383 | try { 384 | $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 385 | } catch (\Exception $exception) { 386 | $this->assertInstanceOf( 387 | 'League\OAuth2\Client\Provider\Exception\IdentityProviderException', 388 | $exception, 389 | 'An invalid exception was thrown: '.get_class($exception) 390 | ); 391 | $this->assertEquals($exception->getMessage(), $errorMessage); 392 | $this->assertEquals($exception->getCode(), $errorStatus); 393 | return; 394 | } 395 | $this->fail('No exception was thrown'); 396 | } 397 | 398 | public function testProviderExceptionThrownWhenErrorObjectReceivedWithoutMessage() 399 | { 400 | $statusCode = rand(400,600); 401 | $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); 402 | $postResponse->shouldReceive('getBody')->andReturn('{"serviceErrorCode": 100}'); 403 | $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 404 | $postResponse->shouldReceive('getStatusCode')->andReturn($statusCode); 405 | $postResponse->shouldReceive('getReasonPhrase')->andReturn('mock reason phrase'); 406 | 407 | $client = m::mock('GuzzleHttp\ClientInterface'); 408 | $client->shouldReceive('send') 409 | ->times(1) 410 | ->andReturn($postResponse); 411 | $this->provider->setHttpClient($client); 412 | 413 | try { 414 | $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 415 | } catch (\Exception $e) { 416 | 417 | $this->assertInstanceOf( 418 | 'League\OAuth2\Client\Provider\Exception\IdentityProviderException', 419 | $e, 420 | 'Unexpected exception thrown' 421 | ); 422 | 423 | $this->assertEquals($e->getMessage(), 'mock reason phrase'); 424 | $this->assertEquals($e->getCode(), $statusCode); 425 | 426 | return; 427 | } 428 | 429 | $this->fail('No exception was thrown'); 430 | } 431 | } 432 | --------------------------------------------------------------------------------