Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API v2 Support #35

Open
wants to merge 4 commits into
base: 1.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 36 additions & 7 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
language: php

php:
- 5.5
- 5.6
- 7.0
- hhvm
matrix:
include:
- php: 5.5
- php: 5.6
- php: 7.0
- php: nightly
- php: hhvm-3.6
sudo: required
dist: trusty
group: edge
- php: hhvm-3.9
sudo: required
dist: trusty
group: edge
- php: hhvm-3.12
sudo: required
dist: trusty
group: edge
- php: hhvm-3.15
sudo: required
dist: trusty
group: edge
- php: hhvm-nightly
sudo: required
dist: trusty
group: edge
fast_finish: true
allow_failures:
- php: nightly
- php: hhvm-3.6
- php: hhvm-3.9
- php: hhvm-3.12
- php: hhvm-3.15
- php: hhvm-nightly

before_script:
- travis_retry composer self-update
Expand All @@ -16,5 +45,5 @@ script:
- ./vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover

after_script:
- wget https://scrutinizer-ci.com/ocular.phar
- php ocular.phar code-coverage:upload --format=php-clover coverage.clover
- if [ "$TRAVIS_PHP_VERSION" == "7.1" ]; then wget https://scrutinizer-ci.com/ocular.phar; fi
- if [ "$TRAVIS_PHP_VERSION" == "7.1" ]; then php ocular.phar code-coverage:upload --format=php-clover coverage.clover; fi
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"linkedin"
],
"require": {
"php": ">=5.5.0",
"php": ">=5.5.0 <7.1",
"league/oauth2-client": "~1.0"
},
"require-dev": {
Expand Down
8 changes: 8 additions & 0 deletions src/Provider/Exception/LinkedInAccessDeniedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace League\OAuth2\Client\Provider\Exception;

class LinkedInAccessDeniedException extends IdentityProviderException
{

}
188 changes: 173 additions & 15 deletions src/Provider/LinkedIn.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

namespace League\OAuth2\Client\Provider;

use Exception;
use InvalidArgumentException;
use League\OAuth2\Client\Grant\AbstractGrant;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Provider\Exception\LinkedInAccessDeniedException;
use League\OAuth2\Client\Token\AccessToken;
use League\OAuth2\Client\Token\LinkedInAccessToken;
use League\OAuth2\Client\Tool\BearerAuthorizationTrait;
use Psr\Http\Message\ResponseInterface;

Expand All @@ -16,18 +21,55 @@ class LinkedIn extends AbstractProvider
*
* @var array
*/
public $defaultScopes = [];
public $defaultScopes = ['r_liteprofile', 'r_emailaddress'];

/**
* Requested fields in scope
* Requested fields in scope, seeded with default values
*
* @var array
* @see https://developer.linkedin.com/docs/fields/basic-profile
*/
public $fields = [
'id', 'email-address', 'first-name', 'last-name', 'headline',
'location', 'industry', 'picture-url', 'public-profile-url',
protected $fields = [
'id', 'firstName', 'lastName', 'localizedFirstName', 'localizedLastName',
'profilePicture(displayImage~:playableStreams)',
];

/**
* Constructs an OAuth 2.0 service provider.
*
* @param array $options An array of options to set on this provider.
* Options include `clientId`, `clientSecret`, `redirectUri`, and `state`.
* Individual providers may introduce more options, as needed.
* @param array $collaborators An array of collaborators that may be used to
* override this provider's default behavior. Collaborators include
* `grantFactory`, `requestFactory`, and `httpClient`.
* Individual providers may introduce more collaborators, as needed.
*/
public function __construct(array $options = [], array $collaborators = [])
{
if (isset($options['fields']) && !is_array($options['fields'])) {
throw new InvalidArgumentException('The fields option must be an array');
}

parent::__construct($options, $collaborators);
}


/**
* Creates an access token from a response.
*
* The grant that was used to fetch the response can be used to provide
* additional context.
*
* @param array $response
* @param AbstractGrant $grant
* @return AccessTokenInterface
*/
protected function createAccessToken(array $response, AbstractGrant $grant)
{
return new LinkedInAccessToken($response);
}

/**
* Get the string used to separate scopes.
*
Expand All @@ -45,7 +87,7 @@ protected function getScopeSeparator()
*/
public function getBaseAuthorizationUrl()
{
return 'https://www.linkedin.com/uas/oauth2/authorization';
return 'https://www.linkedin.com/oauth/v2/authorization';
}

/**
Expand All @@ -55,7 +97,7 @@ public function getBaseAuthorizationUrl()
*/
public function getBaseAccessTokenUrl(array $params)
{
return 'https://www.linkedin.com/uas/oauth2/accessToken';
return 'https://www.linkedin.com/oauth/v2/accessToken';
}

/**
Expand All @@ -67,9 +109,28 @@ public function getBaseAccessTokenUrl(array $params)
*/
public function getResourceOwnerDetailsUrl(AccessToken $token)
{
$fields = implode(',', $this->fields);
$query = http_build_query([
'projection' => '(' . implode(',', $this->fields) . ')'
]);

return 'https://api.linkedin.com/v1/people/~:(' . $fields . ')?format=json';
return 'https://api.linkedin.com/v2/me?' . urldecode($query);
}

/**
* Get provider url to fetch user details
*
* @param AccessToken $token
*
* @return string
*/
public function getResourceOwnerEmailUrl(AccessToken $token)
{
$query = http_build_query([
'q' => 'members',
'projection' => '(elements*(state,primary,type,handle~))'
]);

return 'https://api.linkedin.com/v2/clientAwareMemberHandles?' . urldecode($query);
}

/**
Expand All @@ -88,16 +149,39 @@ protected function getDefaultScopes()
/**
* Check a provider response for errors.
*
* @throws IdentityProviderException
* @param ResponseInterface $response
* @param string $data Parsed response data
* @param array $data Parsed response data
* @return void
* @throws IdentityProviderException
* @see https://developer.linkedin.com/docs/guide/v2/error-handling
*/
protected function checkResponse(ResponseInterface $response, $data)
{
if (isset($data['error'])) {
$this->checkResponseUnauthorized($response, $data);

if ($response->getStatusCode() >= 400) {
throw new IdentityProviderException(
$data['error_description'] ?: $response->getReasonPhrase(),
$data['message'] ?: $response->getReasonPhrase(),
$data['status'] ?: $response->getStatusCode(),
$response
);
}
}

/**
* Check a provider response for unauthorized errors.
*
* @param ResponseInterface $response
* @param array $data Parsed response data
* @return void
* @throws LinkedInAccessDeniedException
* @see https://developer.linkedin.com/docs/guide/v2/error-handling
*/
protected function checkResponseUnauthorized(ResponseInterface $response, $data)
{
if (isset($data['status']) && $data['status'] === 403) {
throw new LinkedInAccessDeniedException(
$data['message'] ?: $response->getReasonPhrase(),
$response->getStatusCode(),
$response
);
Expand All @@ -109,10 +193,84 @@ protected function checkResponse(ResponseInterface $response, $data)
*
* @param array $response
* @param AccessToken $token
* @return League\OAuth2\Client\Provider\ResourceOwnerInterface
* @return LinkedInResourceOwner
*/
protected function createResourceOwner(array $response, AccessToken $token)
{
return new LinkedInResourceOwner($response);
// If current accessToken is not authorized with r_emailaddress scope,
// getResourceOwnerEmail will throw LinkedInAccessDeniedException, it will be caught here,
// and then the email will be set to null
// When email is not available due to chosen scopes, other providers simply set it to null, let's do the same.
try {
$email = $this->getResourceOwnerEmail($token);
} catch (LinkedInAccessDeniedException $exception) {
$email = null;
}

return new LinkedInResourceOwner($response, $email);
}

/**
* Returns the requested fields in scope.
*
* @return array
*/
public function getFields()
{
return $this->fields;
}

/**
* Attempts to fetch resource owner's email address via separate API request.
*
* @param AccessToken $token [description]
* @return string|null
* @throws IdentityProviderException
*/
public function getResourceOwnerEmail(AccessToken $token)
{
$emailUrl = $this->getResourceOwnerEmailUrl($token);
$emailRequest = $this->getAuthenticatedRequest(self::METHOD_GET, $emailUrl, $token);
$emailResponse = $this->getResponse($emailRequest);

return $this->extractEmailFromResponse($emailResponse);
}

/**
* Updates the requested fields in scope.
*
* @param array $fields
*
* @return LinkedIn
*/
public function withFields(array $fields)
{
$this->fields = $fields;

return $this;
}

/**
* Attempts to extract the email address from a valid email api response.
*
* @param array $response
* @return string|null
*/
protected function extractEmailFromResponse($response = [])
{
try {
$confirmedEmails = array_filter($response['elements'], function ($element) {
return
strtoupper($element['type']) === 'EMAIL'
&& strtoupper($element['state']) === 'CONFIRMED'
&& $element['primary'] === true
&& isset($element['handle~']['emailAddress'])
;
});

return $confirmedEmails[0]['handle~']['emailAddress'];
} catch (Exception $e) {
return null;
}
}
}
Loading