diff --git a/README.md b/README.md index c671e8990da4d1b4bc268985f33f21a45eff36d7..1fb9b6f954fad11bc3751137af949cd8cf450a0c 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Almost all OAuth2/OIDC providers will require you to register a redirect URI. Us ## Provider specific Tips * [Google](/docs/GOOGLE.md) + * [LinkedIn](/docs/LINKEDIN.md) * [Microsoft](/docs/MICROSOFT.md) ## Generic Usage diff --git a/attributemap/linkedin2name.php b/attributemap/linkedin2name.php new file mode 100644 index 0000000000000000000000000000000000000000..6c3556eb6a961f8a55ff46a441a8aa7ddb131985 --- /dev/null +++ b/attributemap/linkedin2name.php @@ -0,0 +1,9 @@ +<?php +$attributemap = array( + + 'linkedin.firstName' => 'givenName', + 'linkedin.lastName' => 'sn', + 'linkedin.id' => 'uid', // any b64 character + 'linkedin.emailAddress' => 'mail', + +); diff --git a/docs/GOOGLE.md b/docs/GOOGLE.md index 03fa401177a1ad3961c8776b0537cfcb3c6bebce..3982a77989004cf423e4e619494595c896ca4607 100644 --- a/docs/GOOGLE.md +++ b/docs/GOOGLE.md @@ -12,12 +12,13 @@ # Google as an AuthSource -Google provides both OIDC and Google Plus endpoints for learning about -a user. The OIDC endpoints require fewer client API permissions and -return data in a standardized format. The Google Plus endpoints can -return more data about a user but require Google Plus permissions and -return data in a Google specific format. The Google Plus apis will be shutting down sometime in 2019 -so we recommend using the OIDC endpoints +Google provides OIDC (and previously Google Plus endpoints for +learning about a user). The OIDC endpoints require fewer client API +permissions and return data in a standardized format. The Google Plus +endpoints can return more data about a user but require Google Plus +permissions and return data in a Google specific format. The Google +Plus apis will be shutting down sometime in 2019 so we recommend using +the OIDC endpoints You can also choose between using the generic OAuth/OIDC implementation or using a [Google specific library](https://github.com/thephpleague/oauth2-google/). @@ -25,7 +26,7 @@ a [Google specific library](https://github.com/thephpleague/oauth2-google/). # Usage ## Recommended Config -We recommend using the OIDC configuration with the generic implementation. This +We recommend using the OIDC configuration with the generic OAuth2 authsource. This requires the least configuration. @@ -59,6 +60,7 @@ If you want to restrict the hosted domain of a user you can pass the returned from Google matches what you expect - a user could remove the `hd` from the browser flow and login with any account. +* Out of date * TODO: Once https://github.com/thephpleague/oauth2-google/pull/54 is accepted into the oauth2-google project then this check would be done automatically. This example would then need to be updated to use that project diff --git a/docs/LINKEDIN.md b/docs/LINKEDIN.md new file mode 100644 index 0000000000000000000000000000000000000000..9f25f04adfd5b10405c41d28a98da2b48941512c --- /dev/null +++ b/docs/LINKEDIN.md @@ -0,0 +1,50 @@ +<!-- START doctoc generated TOC please keep comment here to allow auto update --> +<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> +**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* + +- [LinkedIn as authsource](#linkedin-as-authsource) +- [Usage](#usage) +- [Migrarting from OAuth v1 authlinkedin](#migrarting-from-oauth-v1-authlinkedin) + +<!-- END doctoc generated TOC please keep comment here to allow auto update --> + +# LinkedIn as authsource + +LinkedIn recommends using OAuth2 and their v2 apis. Their v1 apis and +OAuth1 endpoints are being shutdown. LinkedIn v2 apis return data in a +more complex format (json keys change based on language) and require +additional API calls to get an email address. You need to use the +`authoauth2:LinkedInV2Auth` authsource since LinkedIn doesn't conform +the expected OIDC/OAuth pattern. + +# Usage + +```php + 'linkedin' => [ + 'authoauth2:LinkedInV2Auth', + 'clientId' => $apiKey, + 'clientSecret' => $apiSecret, + // Adjust the scopes: default is to request email and liteprofile + // 'scopes' => ['r_liteprofile'], + ], +``` + +# Migrarting from OAuth v1 authlinkedin + +The `authlinkedin` module bundled with most version of SSP uses +deprecated OAuth v1 and v1 API endpoints. To migrate to the new +LinkedIn api you will need to add a [redirect URI to your linkedin +application](https://docs.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow?context=linkedin/consumer/context). The +redirect URI is + + https://hostname/SSP_PATH/module.php/authoauth2/linkback.php + +You will then need to change your `authsource` configuration to match the example usage above. + +On your idp side you may need to use `linkedin2name` from this module rather than `authlinkedin`. + +```php + // Convert linkedin names to ldap friendly names + 10 => array('class' => 'core:AttributeMap', 'authoauth2:linkedin2name'), +``` +There are some minor changes in user experience and consent which are outlined in [our blog post](https://blog.cirrusidentity.com/linkedin-user-interaction-changes). \ No newline at end of file diff --git a/lib/Auth/Source/LinkedInV2Auth.php b/lib/Auth/Source/LinkedInV2Auth.php new file mode 100644 index 0000000000000000000000000000000000000000..aa00afd6aa0e2f439b1b81e89f1698ece545e037 --- /dev/null +++ b/lib/Auth/Source/LinkedInV2Auth.php @@ -0,0 +1,138 @@ +<?php +/** + * Created by PhpStorm. + * User: patrick + * Date: 10/16/18 + * Time: 1:34 PM + */ + +namespace SimpleSAML\Module\authoauth2\Auth\Source; + +use League\OAuth2\Client\Provider\AbstractProvider; +use League\OAuth2\Client\Token\AccessToken; +use SimpleSAML\Logger; +use SimpleSAML\Module\authoauth2\ConfigTemplate; + +/** + * LinkedIn's v2 api requires a 2nd call to determine email address. + */ +class LinkedInV2Auth extends OAuth2 +{ + + public function __construct(array $info, array $config) + { + // Set some defaults + if (!array_key_exists('template', $config)) { + $config['template'] = 'LinkedInV2'; + } + parent::__construct($info, $config); + } + + public function convertResourceOwnerAttributes(array $resourceOwnerAttributes, $prefix) + { + /** Sample response from LinkedIn + * { + * "lastName":{ + * "localized":{ + * "en_US":"Lasty" + * }, + * "preferredLocale":{ + * "country":"US", + * "language":"en" + * } + * }, + * "firstName":{ + * "localized":{ + * "en_US":"Firsty" + * }, + * "preferredLocale":{ + * "country":"US", + * "language":"en" + * } + * }, + * "id":"t7xZ7s4F02" + * } + * With 'preferredLocale' being optional + */ + $attributes = [ + $prefix . "id" => [$resourceOwnerAttributes["id"]] + ]; + foreach (['firstName', 'lastName'] as $attributeName) { + $value = $this->getFirstValueFromMultiLocaleString($attributeName, $resourceOwnerAttributes); + if ($value) { + $attributes[$prefix . $attributeName] = [$value]; + } + } + + + return $attributes; + } + + /** + * LinkedIn's attribute values are complex subobjects per + * https://docs.microsoft.com/en-us/linkedin/shared/references/v2/object-types#multilocalestring + * @param string $attributeName The multiLocalString attribute to check + * @param array $attributes All the linkedIn attributes + * @return string|false|null Return the first value or null/false if there is no value + */ + private function getFirstValueFromMultiLocaleString($attributeName, array $attributes) + { + if (isset($attributes[$attributeName]['localized'])) { + // reset gives us the first value from the multi valued associate localized array + return reset($attributes[$attributeName]['localized']); + } + return null; + } + + + /** + * Query LinkedIn's email endpoint if needed. + * Public for testing + * @param AccessToken $accessToken + * @param AbstractProvider $provider + * @param array $state + */ + public function postFinalStep(AccessToken $accessToken, AbstractProvider $provider, &$state) + { + if (!in_array('r_emailaddress', $this->config->getArray('scopes'))) { + // We didn't request email scope originally + return; + } + $emailUrl = $this->getConfig()->getString('urlResourceOwnerEmail'); + $request = $provider->getAuthenticatedRequest('GET', $emailUrl, $accessToken); + try { + $response = $this->retry( + function () use ($provider, $request) { + return $provider->getParsedResponse($request); + }, + $this->config->getInteger('retryOnError', 1) + ); + } catch (\Exception $e) { + // not getting email shouldn't fail the authentication + Logger::error( + 'linkedInv2Auth: ' . $this->getLabel() . ' exception email query response ' . $e->getMessage() + ); + return; + } + + if (is_array($response) && isset($response["elements"][0]["handle~"]["emailAddress"])) { + /** + * A valid response for email lookups is: + * { + * "elements" : [ { + * "handle" : "urn:li:emailAddress:5266785132", + * "handle~" : { + * "emailAddress" : "patrick+testuser@cirrusidentity.com" + * } + * } ] + * } + */ + $prefix = $this->getAttributePrefix(); + $state['Attributes'][$prefix . 'emailAddress'] = [$response["elements"][0]["handle~"]["emailAddress"]]; + } else { + Logger::error( + 'linkedInv2Auth: ' . $this->getLabel() . ' invalid email query response ' . var_export($response, true) + ); + } + } +} diff --git a/lib/Auth/Source/MicrosoftHybridAuth.php b/lib/Auth/Source/MicrosoftHybridAuth.php index f6451afcfeac17f7bd71c13e82f067bcc344ca3e..78a523888b8b91bda2fab0d05452c021398f9f4c 100644 --- a/lib/Auth/Source/MicrosoftHybridAuth.php +++ b/lib/Auth/Source/MicrosoftHybridAuth.php @@ -46,7 +46,7 @@ class MicrosoftHybridAuth extends OAuth2 } $idTokenData = $this->extraIdTokenAttributes($accessToken->getValues()['id_token']); - $prefix = $this->config->getString('attributePrefix', ''); + $prefix = $this->getAttributePrefix(); if (array_key_exists('email', $idTokenData)) { $state['Attributes'][$prefix . 'mail'] = [$idTokenData['email']]; diff --git a/lib/Auth/Source/OAuth2.php b/lib/Auth/Source/OAuth2.php index a7f5c8b010882a5d3e68202ab129a8efabcf3902..e56b6e938d1e270ae7a6f019f1ab39e746ee490c 100644 --- a/lib/Auth/Source/OAuth2.php +++ b/lib/Auth/Source/OAuth2.php @@ -7,16 +7,15 @@ use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\HandlerStack; use GuzzleHttp\MessageFormatter; use GuzzleHttp\Middleware; -use League\OAuth2\Client\Provider\GenericProvider; use League\OAuth2\Client\Provider\AbstractProvider; +use League\OAuth2\Client\Provider\ResourceOwnerInterface; use League\OAuth2\Client\Token\AccessToken; -use SAML2\Utils; use SimpleSAML\Logger; +use SimpleSAML\Module; use SimpleSAML\Module\authoauth2\AttributeManipulator; use SimpleSAML\Module\authoauth2\ConfigTemplate; use SimpleSAML\Module\authoauth2\Providers\AdjustableGenericProvider; use SimpleSAML\Module\authoauth2\PsrLogBridge; -use SimpleSAML\Utils\Arrays; use SimpleSAML\Utils\HTTP; /** @@ -69,7 +68,7 @@ class OAuth2 extends \SimpleSAML_Auth_Source } } if (!array_key_exists('redirectUri', $config)) { - $config['redirectUri'] = \SimpleSAML\Module::getModuleURL('authoauth2/linkback.php'); + $config['redirectUri'] = Module::getModuleURL('authoauth2/linkback.php'); } if (!array_key_exists('timeout', $config)) { $config['timeout'] = 3; @@ -190,6 +189,7 @@ class OAuth2 extends \SimpleSAML_Auth_Source Logger::debug('authoauth2: ' . $providerLabel . ' id_token json: ' . $decodedIdToken); } + /** @var ResourceOwnerInterface $resourceOwner */ $resourceOwner = $this->retry( function () use ($provider, $accessToken) { return $provider->getResourceOwner($accessToken); @@ -198,9 +198,8 @@ class OAuth2 extends \SimpleSAML_Auth_Source ); $attributes = $resourceOwner->toArray(); - $prefix = $this->config->getString('attributePrefix', ''); - $attributeManipulator = new AttributeManipulator(); - $state['Attributes'] = $attributeManipulator->prefixAndFlatten($attributes, $prefix); + $prefix = $this->getAttributePrefix(); + $state['Attributes'] = $this->convertResourceOwnerAttributes($attributes, $prefix); $this->postFinalStep($accessToken, $provider, $state); Logger::debug( 'authoauth2: ' . $providerLabel . ' attributes: ' . implode(", ", array_keys($state['Attributes'])) @@ -210,6 +209,19 @@ class OAuth2 extends \SimpleSAML_Auth_Source Logger::debug('authoauth2: ' . $providerLabel . ' finished authentication in ' . $time . ' seconds'); } + /** + * Take the array of users attributes from the Oauth2 provider and convert them into a form usable by SSP. + * The default implementation attempts to flatten the user attribute structure and prefix the attribute names + * @param array $resourceOwnerAttributes The array of attributes from the OAuth2/OIDC provider + * @param string $prefix A string to put in front of all attribute names + * @return array The SSP attributes, in form suitable to assign to $state['Attributes'] + */ + protected function convertResourceOwnerAttributes(array $resourceOwnerAttributes, $prefix) + { + $attributeManipulator = new AttributeManipulator(); + return $attributeManipulator->prefixAndFlatten($resourceOwnerAttributes, $prefix); + } + /** * Retry token and user info endpoints in event of network errors. * @param callable $function the function to try @@ -290,4 +302,9 @@ class OAuth2 extends \SimpleSAML_Auth_Source { return $this->config; } + + protected function getAttributePrefix() + { + return $this->config->getString('attributePrefix', ''); + } } diff --git a/lib/ConfigTemplate.php b/lib/ConfigTemplate.php index 378e4ee01666f5d5b083150198959666a6a9c177..bc9d808efeb8a8167deb9ecb88adcb98f9706be1 100644 --- a/lib/ConfigTemplate.php +++ b/lib/ConfigTemplate.php @@ -44,6 +44,7 @@ class ConfigTemplate 'label' => 'google' ]; + // Deprecated const LinkedIn = [ 'authoauth2:OAuth2', // *** LinkedIn Endpoints *** @@ -58,6 +59,25 @@ class ConfigTemplate 'label' => 'linkedin' ]; + const LinkedInV2 = [ + 'authoauth2:LinkedInV2Auth', + // *** LinkedIn Endpoints *** + 'urlAuthorize' => 'https://www.linkedin.com/oauth/v2/authorization', + 'urlAccessToken' => 'https://www.linkedin.com/oauth/v2/accessToken', + 'urlResourceOwnerDetails' => 'https://api.linkedin.com/v2/me', + 'urlResourceOwnerEmail' => 'https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))', + //scopes are the default ones configured for your application + 'attributePrefix' => 'linkedin.', + 'scopes' => [ + 'r_liteprofile', + // This requires additional api call to the urlResourceOwnerEmail url + 'r_emailaddress', + ], + 'scopeSeparator' => ' ', + // Improve log lines + 'label' => 'linkedin' + ]; + //https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc //https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration // WARNING: The OIDC user resource endpoint only returns sub, which is a targeted id. @@ -115,6 +135,7 @@ class ConfigTemplate 'label' => 'yahoo' ]; + // TODO: weibo is work in progress const Weibo = [ 'authoauth2:OAuth2', // *** Weibo Endpoints *** diff --git a/lib/OAuth2ResponseHandler.php b/lib/OAuth2ResponseHandler.php index 9735e85a7e718c855576c0bfe12dbad1b0b883b7..0d981589580e8e32bd83299568e74fca90b1260b 100644 --- a/lib/OAuth2ResponseHandler.php +++ b/lib/OAuth2ResponseHandler.php @@ -25,7 +25,13 @@ class OAuth2ResponseHandler * 'access_denied' is OAuth2 standard. Some AS made up their own codes, so support the common ones. * @var string[] */ - private $errorsUserConsent = ['access_denied', 'user_denied', 'user_cancelled_authorize', 'consent_required', 'user_cancelled_login']; + private $errorsUserConsent = [ + 'access_denied', + 'user_denied', + 'user_cancelled_authorize', + 'consent_required', + 'user_cancelled_login' + ]; /** * Look at the state parameter returned by the OAuth2 server and determine if we can handle it; diff --git a/tests/lib/Auth/Source/LinkedInV2AuthTest.php b/tests/lib/Auth/Source/LinkedInV2AuthTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6a66e17cc901f20648166f130274c4fb9f019190 --- /dev/null +++ b/tests/lib/Auth/Source/LinkedInV2AuthTest.php @@ -0,0 +1,136 @@ +<?php + +namespace Test\SimpleSAML\Auth\Source; + +use League\OAuth2\Client\Provider\AbstractProvider; +use League\OAuth2\Client\Token\AccessToken; +use Psr\Http\Message\RequestInterface; +use SimpleSAML\Module\authoauth2\Auth\Source\LinkedInV2Auth; + +class LinkedInV2AuthTest extends \PHPUnit_Framework_TestCase +{ + public static function setUpBeforeClass() + { + putenv('SIMPLESAMLPHP_CONFIG_DIR=' . dirname(dirname(dirname(__DIR__))) . '/config'); + } + + /** + * Confirms linkedIn's attribute structure gets converted correctly + * @dataProvider attributeConversionProvider + * @param array $userAttributes The attributes from the linkedIn endpoint + * @param array $expectedAttributes The expected attributes + */ + public function testAttributeConversion(array $userAttributes, array $expectedAttributes) + { + $linkedInAuth = new LinkedInV2Auth(['AuthId' => 'linked'], []); + $attributes = $linkedInAuth->convertResourceOwnerAttributes($userAttributes, 'linkedin.'); + $this->assertEquals($expectedAttributes, $attributes); + } + + public function attributeConversionProvider() + { + return [ + [["id" => "abc"], ["linkedin.id" => ["abc"]]], + [ + [ + "id" => "abc", + "firstName" => ["localized" => ["en_US" => "Jon", "en_CA" => "John"]], + "lastName" => ['not-used'] + ], + ["linkedin.id" => ["abc"], 'linkedin.firstName' => ["Jon"]] + ], + [ + [ + "id" => "abc", + "firstName" => ["localized" => ["en_US" => "Jon", "en_CA" => "John"]], + "lastName" => ["localized" => ["en_CA" => "Smith"]], + ], + ["linkedin.id" => ["abc"], 'linkedin.firstName' => ["Jon"], 'linkedin.lastName' => ["Smith"]] + ], + ]; + } + + public function testNoEmailCallIfNotRequested() + { + $linkedInAuth = new LinkedInV2Auth(['AuthId' => 'linked'], ['scopes' => ['r_liteprofile']]); + $state = []; + /** + * @var $mock AbstractProvider|\PHPUnit_Framework_MockObject_MockObject + */ + $mock = $this->getMockBuilder(AbstractProvider::class) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects($this->never()) + ->method('getAuthenticatedRequest'); + $linkedInAuth->postFinalStep(new AccessToken(['access_token' => 'abc']), $mock, $state); + + $this->assertEquals([], $state, "State array not changed"); + } + + /** + * @dataProvider getEmailProvider + * @param array $emailResponse The response from the email endpoint + * @param array $expectedAttributes What the SSP attributes are expected to be + */ + public function testGettingEmail(array $emailResponse, array $expectedAttributes) + { + $linkedInAuth = new LinkedInV2Auth(['AuthId' => 'linked'], []); + $state = [ + 'Attributes' => [ + 'linkedin.id' => ['abc'] + ] + ]; + + $token = new AccessToken(['access_token' => 'abc']); + /** + * @var $mock AbstractProvider|\PHPUnit_Framework_MockObject_MockObject + */ + $mock = $this->getMockBuilder(AbstractProvider::class) + ->disableOriginalConstructor() + ->getMock(); + + $mockRequest = $this->createMock(RequestInterface::class); + $mock->method('getAuthenticatedRequest') + ->with('GET', 'https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))', $token) + ->willReturn($mockRequest); + + $mock->method('getParsedResponse') + ->with($mockRequest) + ->willReturn($emailResponse); + $linkedInAuth->postFinalStep($token, $mock, $state); + + $this->assertEquals( + $expectedAttributes, + $state['Attributes'], + "mail should be added" + ); + } + + public function getEmailProvider() + { + return [ + [ + // valid email response + [ + "elements" => [ + [ + "handle" => "urn:li:emailAddress:5266785132", + "handle~" => [ + "emailAddress" => "testuser@cirrusidentity.com" + ] + ] + ] + ], + // email added + ['linkedin.id' => ['abc'], 'linkedin.emailAddress' => ['testuser@cirrusidentity.com']] + ], + [ + [ + 'someerror' => 'errormessage' + ], + // email not added + ['linkedin.id' => ['abc']], + ], + ]; + } +}