From a17b2d8a59b3168b5c9b9caad2a1306c5e70e7d6 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 27 Oct 2020 06:15:43 -0700 Subject: [PATCH] dev/core#2141 - Add services 'oauth2.league' and 'oauth2.token' --- .../Civi/OAuth/OAuthException.php | 6 + .../Civi/OAuth/OAuthLeagueFacade.php | 114 +++++++++++++++ .../Civi/OAuth/OAuthTokenFacade.php | 136 ++++++++++++++++++ ext/oauth-client/oauth_client.php | 11 ++ 4 files changed, 267 insertions(+) create mode 100644 ext/oauth-client/Civi/OAuth/OAuthException.php create mode 100644 ext/oauth-client/Civi/OAuth/OAuthLeagueFacade.php create mode 100644 ext/oauth-client/Civi/OAuth/OAuthTokenFacade.php diff --git a/ext/oauth-client/Civi/OAuth/OAuthException.php b/ext/oauth-client/Civi/OAuth/OAuthException.php new file mode 100644 index 0000000000..70108a9fad --- /dev/null +++ b/ext/oauth-client/Civi/OAuth/OAuthException.php @@ -0,0 +1,6 @@ +createProviderOptions($clientDef); + return new $class($options); + } + + /** + * @param array $clientDef + * The OAuthClient record. This may be a full record, or it may be + * brief stub with 'id' or 'provider'. (In which case, it will look for + * exactly one matching client.) + * @return array + */ + public function createProviderOptions($clientDef) { + $clientDef = $this->resolveSingleRef('OAuthClient', $clientDef, ['id', 'provider'], ['secret', 'guid']); + $providerDef = \Civi\Api4\OAuthProvider::get(0) + ->addWhere('name', '=', $clientDef['provider']) + ->execute() + ->single(); + + $class = $providerDef['class']; + + $localOptions = []; + $localOptions['clientId'] = $clientDef['guid']; + $localOptions['clientSecret'] = $clientDef['secret']; + // NOTE: If we ever have frontend users, this may need to change. + $localOptions['redirectUri'] = \CRM_Utils_System::url('civicrm/oauth-client/return', NULL, TRUE, NULL, FALSE); + $options = array_merge( + $providerDef['options'] ?? [], + $clientDef['options'] ?? [], + $localOptions + ); + + return [$class, $options]; + } + + /** + * Create an instance of the PHP League's OAuth2 client for interacting with + * a given token. + * + * @param array $tokenRecord + * @return array + * An array with properties: + * - provider: League\OAuth2\Client\Provider\AbstractProvider + * - token: League\OAuth2\Client\Token\AccessTokenInterface + * @throws \Civi\OAuth\OAuthException + */ + public function create($tokenRecord) { + $tokenRecord = $this->resolveSingleRef('OAuthSysToken', $tokenRecord, ['id'], ['client_id', 'raw']); + $provider = $this->createProvider(['id' => $tokenRecord['client_id']]); + $token = new \League\OAuth2\Client\Token\AccessToken($tokenRecord['raw']); + return [ + 'provider' => $provider, + 'token' => $token, + ]; + } + + /** + * Given a $record, determine if it is complete enough for usage. If not, + * attempt to load the full record. Throw an exception if we don't find it. + * + * @param string $entity + * The of record that we want to load. (APIv4 entity) + * @param array $record + * A complete or partial API record + * @param array $lookupFields + * A list of key fields that can be used to lookup records. + * @param array $requireFields + * A list of data fields that we need to have. + * @return array + * @throws \Civi\OAuth\OAuthException + */ + protected function resolveSingleRef($entity, $record, $lookupFields, $requireFields) { + $requireFields = array_unique(array_merge($lookupFields, $requireFields)); + $hasReqs = TRUE; + foreach ($requireFields as $field) { + $hasReqs = $hasReqs && isset($record[$field]); + } + + if ($hasReqs) { + return $record; + } + + $where = []; + foreach ($lookupFields as $field) { + if (isset($record[$field])) { + $where[] = [$field, '=', $record[$field]]; + + } + } + + if (empty($where)) { + throw new OAuthException("Incomplete reference to $entity. Must have at least one of these fields: " . implode(',', $lookupFields)); + } + + return civicrm_api4($entity, 'get', [ + 'where' => $where, + 'checkPermissions' => FALSE, + ])->single(); + } + +} diff --git a/ext/oauth-client/Civi/OAuth/OAuthTokenFacade.php b/ext/oauth-client/Civi/OAuth/OAuthTokenFacade.php new file mode 100644 index 0000000000..7956a87661 --- /dev/null +++ b/ext/oauth-client/Civi/OAuth/OAuthTokenFacade.php @@ -0,0 +1,136 @@ +createProvider($options['client']); + $scopeSeparator = $this->callProtected($provider, 'getScopeSeparator'); + + $sendOptions = $options['cred'] ?? []; + if (isset($options['scope']) && $options['scope'] !== NULL) { + switch ($options['grant_type']) { + case 'authorization_code': + // already sent. + break; + + default: + $sendOptions['scope'] = $this->implodeScopes($scopeSeparator, $options['scope']); + } + } + + /** @var \League\OAuth2\Client\Token\AccessToken $accessToken */ + $accessToken = $provider->getAccessToken($options['grant_type'], $sendOptions); + $values = $accessToken->getValues(); + + $tokenRecord = [ + 'client_id' => $options['client']['id'], + 'grant_type' => $options['grant_type'], + 'scopes' => $this->splitScopes($scopeSeparator, $values['scope'] ?? $options['scope'] ?? NULL), + 'token_type' => $values['token_type'] ?? NULL, + 'access_token' => $accessToken->getToken(), + 'refresh_token' => $accessToken->getRefreshToken(), + 'expires' => $accessToken->getExpires(), + 'raw' => $accessToken->jsonSerialize(), + ]; + try { + $owner = $provider->getResourceOwner($accessToken); + $tokenRecord['resource_owner_name'] = $this->findName($owner); + $tokenRecord['resource_owner'] = $owner->toArray(); + } + catch (\Throwable $e) { + \Civi::log()->warning("Failed to resolve resource_owner"); + } + + return civicrm_api4($options['storage'], 'create', [ + 'checkPermissions' => FALSE, + 'values' => $tokenRecord, + ])->single(); + } + + /** + * Call a protected method. + * + * @param mixed $obj + * @param string $method + * @param array $args + * @return mixed + */ + protected function callProtected($obj, $method, $args = []) { + $r = new \ReflectionMethod(get_class($obj), $method); + $r->setAccessible(TRUE); + return $r->invokeArgs($obj, $args); + } + + /** + * @param string $delim + * @param string|array|null $scopes + * @return array|null + */ + protected function splitScopes($delim, $scopes) { + if ($scopes === NULL || is_array($scopes)) { + return $scopes; + } + if ($scopes === '') { + return []; + } + if (is_string($scopes)) { + return explode($delim, $scopes); + } + \Civi::log()->warning("Failed to explode scopes", [ + 'scopes' => $scopes, + ]); + return NULL; + } + + protected function implodeScopes($delim, $scopes) { + if ($scopes === NULL || is_string($scopes)) { + return $scopes; + } + if (is_array($scopes)) { + return implode($delim, $scopes); + } + \Civi::log()->warning("Failed to implode scopes", [ + 'scopes' => $scopes, + ]); + return NULL; + } + + protected function findName(ResourceOwnerInterface $owner) { + $values = $owner->toArray(); + $fields = ['upn', 'userPrincipalName', 'mail', 'id']; + foreach ($fields as $field) { + if (isset($values[$field])) { + return $values[$field]; + } + } + return $owner->getId(); + } + +} diff --git a/ext/oauth-client/oauth_client.php b/ext/oauth-client/oauth_client.php index 804658a299..cc24a4a419 100644 --- a/ext/oauth-client/oauth_client.php +++ b/ext/oauth-client/oauth_client.php @@ -189,6 +189,17 @@ function oauth_client_civicrm_themes(&$themes) { // _oauth_client_civix_navigationMenu($menu); //} +/** + * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container + */ +function oauth_client_civicrm_container($container) { + $container->addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__)); + $container->setDefinition('oauth2.league', new \Symfony\Component\DependencyInjection\Definition( + \Civi\OAuth\OAuthLeagueFacade::class, []))->setPublic(TRUE); + $container->setDefinition('oauth2.token', new \Symfony\Component\DependencyInjection\Definition( + \Civi\OAuth\OAuthTokenFacade::class, []))->setPublic(TRUE); +} + /** * Implements hook_civicrm_oauthProviders(). */ -- 2.25.1