Initial commit

This commit is contained in:
root
2026-01-19 17:44:46 +01:00
commit 823af8b11d
8721 changed files with 1130846 additions and 0 deletions

View File

@@ -0,0 +1,459 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\ExternalAccount;
use Espo\Core\Exceptions\Error;
use Espo\Core\Field\DateTime;
use Espo\Core\Utils\Language;
use Espo\Entities\Integration;
use Espo\Entities\ExternalAccount;
use Espo\Entities\Notification;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
use Espo\Core\ExternalAccount\Clients\IClient;
use Espo\Core\ExternalAccount\OAuth2\Client as OAuth2Client;
use Espo\Core\InjectableFactory;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Name\Attribute;
use RuntimeException;
class ClientManager
{
private const REFRESH_TOKEN_ATTEMPTS_LIMIT = 20;
private const REFRESH_TOKEN_ATTEMPTS_PERIOD = '1 day';
/** @var array<string, (array<string, mixed> & array{externalAccountEntity: ExternalAccount})> */
protected $clientMap = [];
public function __construct(
protected EntityManager $entityManager,
protected Metadata $metadata,
protected Config $config,
protected ?InjectableFactory $injectableFactory = null,
private ?Language $language = null
) {}
/**
* @param array{
* accessToken: ?string,
* tokenType: ?string,
* expiresAt?: ?string,
* refreshToken?: ?string,
* } $data
* @throws Error
*/
public function storeAccessToken(object $client, array $data): void
{
try {
$account = $this->getClientRecord($client);
} catch (Error) {
// @todo Revise.
return;
}
$account->setAccessToken($data['accessToken']);
$account->setTokenType($data['tokenType']);
$account->setExpiresAt($data['expiresAt'] ?? null);
$account->setRefreshTokenAttempts(null);
if ($data['refreshToken'] ?? null) {
$account->setRefreshToken($data['refreshToken']);
}
/** @var ?ExternalAccount $account */
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, $account->getId());
if (!$account) {
throw new Error("External Account: Account removed.");
}
if (!$account->isEnabled()) {
throw new Error("External Account: Account disabled.");
}
$account->setAccessToken($data['accessToken']);
$account->setTokenType($data['tokenType']);
$account->setExpiresAt($data['expiresAt'] ?? null);
$account->setRefreshTokenAttempts(null);
if ($data['refreshToken'] ?? null) {
$account->setRefreshToken($data['refreshToken'] ?? null);
}
$this->entityManager->saveEntity($account, [
'isTokenRenewal' => true,
SaveOption::SKIP_HOOKS => true,
]);
}
/**
* @throws Error
*/
public function create(string $integration, string $userId): ?object
{
$authMethod = $this->metadata->get("integrations.$integration.authMethod");
if (ucfirst($authMethod) === 'OAuth2') {
return $this->createOAuth2($integration, $userId);
}
$methodName = 'create' . ucfirst($authMethod);
if (method_exists($this, $methodName)) {
return $this->$methodName($integration, $userId);
}
if (!$this->injectableFactory) {
throw new RuntimeException("No injectableFactory.");
}
/** @var ?Integration $integrationEntity */
$integrationEntity = $this->entityManager->getEntityById(Integration::ENTITY_TYPE, $integration);
/** @var ?ExternalAccount $account */
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, "{$integration}__$userId");
if (!$account) {
throw new Error("External Account $integration not found for $userId.");
}
if (!$integrationEntity || !$integrationEntity->isEnabled() || !$account->isEnabled()) {
return null;
}
/** @var class-string $className */
$className = $this->metadata->get("integrations.$integration.clientClassName");
$client = $this->injectableFactory->create($className);
if (!method_exists($client, 'setup')) {
throw new RuntimeException("$className does not have `setup` method.");
}
$client->setup(
$userId,
$integrationEntity,
$account,
$this
);
$this->addToClientMap($client, $integrationEntity, $account, $userId);
return $client;
}
/**
* @throws Error
*/
protected function createOAuth2(string $integration, string $userId): ?object
{
/** @var ?Integration $integrationEntity */
$integrationEntity = $this->entityManager->getEntityById(Integration::ENTITY_TYPE, $integration);
/** @var ?ExternalAccount $account */
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, "{$integration}__$userId");
/** @var class-string $className */
$className = $this->metadata->get("integrations.$integration.clientClassName");
$redirectUri = $this->config->get('siteUrl') . '?entryPoint=oauthCallback';
$redirectUriPath = $this->metadata->get(['integrations', $integration, 'params', 'redirectUriPath']);
if ($redirectUriPath) {
$redirectUri = rtrim($this->config->get('siteUrl'), '/') . '/' . $redirectUriPath;
}
if (!$account) {
throw new Error("External Account $integration not found for '$userId'.");
}
if (
!$integrationEntity ||
!$integrationEntity->isEnabled() ||
!$account->isEnabled()
) {
return null;
}
$oauth2Client = new OAuth2Client();
$params = [
'endpoint' => $this->metadata->get("integrations.$integration.params.endpoint"),
'tokenEndpoint' => $this->metadata->get("integrations.$integration.params.tokenEndpoint"),
'clientId' => $integrationEntity->get('clientId'),
'clientSecret' => $integrationEntity->get('clientSecret'),
'redirectUri' => $redirectUri,
'accessToken' => $account->getAccessToken(),
'refreshToken' => $account->getRefreshToken(),
'tokenType' => $account->getTokenType(),
'expiresAt' => $account->getExpiresAt() ? $account->getExpiresAt()->toString() : null,
];
$authType = $this->metadata->get("integrations.$integration.authType");
$tokenType = $this->metadata->get("integrations.$integration.tokenType");
if ($authType === 'Uri') {
$oauth2Client->setAuthType(OAuth2Client::AUTH_TYPE_URI);
} else if ($authType === 'Basic') {
$oauth2Client->setAuthType(OAuth2Client::AUTH_TYPE_AUTHORIZATION_BASIC);
} else if ($authType === 'Form') {
$oauth2Client->setAuthType(OAuth2Client::AUTH_TYPE_FORM);
}
if ($tokenType === 'Bearer') {
$oauth2Client->setTokenType(OAuth2Client::TOKEN_TYPE_BEARER);
} else if ($authType === 'Uri') {
$oauth2Client->setTokenType(OAuth2Client::TOKEN_TYPE_URI);
} else if ($authType === 'OAuth') {
$oauth2Client->setTokenType(OAuth2Client::TOKEN_TYPE_OAUTH);
}
foreach (get_object_vars($integrationEntity->getValueMap()) as $k => $v) {
if (array_key_exists($k, $params)) {
continue;
}
if ($integrationEntity->hasAttribute($k)) {
continue;
}
$params[$k] = $v;
}
if ($this->injectableFactory) {
$client = $this->injectableFactory->createWith($className, [
'client' => $oauth2Client,
'params' => $params,
'manager' => $this,
]);
} else {
// For backward compatibility.
$client = new $className($oauth2Client, $params, $this);
}
$this->addToClientMap($client, $integrationEntity, $account, $userId);
return $client;
}
/**
* @param object $client
* @return void
*/
protected function addToClientMap(
$client,
Integration $integration,
ExternalAccount $account,
string $userId
) {
$this->clientMap[spl_object_hash($client)] = [
'client' => $client,
'userId' => $userId,
'integration' => $integration->getId(),
'integrationEntity' => $integration,
'externalAccountEntity' => $account,
];
}
/**
* @param object $client
* @throws Error
*/
protected function getClientRecord($client): ExternalAccount
{
$data = $this->clientMap[spl_object_hash($client)] ?? null;
if (!$data) {
throw new Error("External Account: Client not found in hash.");
}
if (!isset($data['externalAccountEntity'])) {
throw new Error("External Account: Account not found in hash.");
}
return $data['externalAccountEntity'];
}
/**
* @param object $client
* @throws Error
*/
public function isClientLocked($client): bool
{
$accountSet = $this->getClientRecord($client);
$account = $this->fetchAccountOnlyWithIsLocked($accountSet->getId());
return $account->isLocked();
}
/**
* @throws Error
*/
public function lockClient(object $client): void
{
$accountSet = $this->getClientRecord($client);
$account = $this->fetchAccountOnlyWithIsLocked($accountSet->getId());
$account->setIsLocked(true);
$this->entityManager->saveEntity($account, [
SaveOption::SKIP_HOOKS => true,
SaveOption::SILENT => true,
]);
}
/**
* @throws Error
*/
public function unlockClient(object $client): void
{
$accountSet = $this->getClientRecord($client);
$accountSet = $this->fetchAccountOnlyWithIsLocked($accountSet->getId());
$accountSet->setIsLocked(false);
$this->entityManager->saveEntity($accountSet, [
SaveOption::SKIP_HOOKS => true,
SaveOption::SILENT => true,
]);
}
/**
* @throws Error
*/
public function controlRefreshTokenAttempts(object $client): void
{
$accountSet = $this->getClientRecord($client);
$account = $this->entityManager
->getRDBRepositoryByClass(ExternalAccount::class)
->getById($accountSet->getId());
if (!$account) {
return;
}
$attempts = $account->getRefreshTokenAttempts();
$account->setRefreshTokenAttempts($attempts + 1);
if (
$attempts >= self::REFRESH_TOKEN_ATTEMPTS_LIMIT &&
$account->getExpiresAt() &&
$account->getExpiresAt()
->modify('+' . self::REFRESH_TOKEN_ATTEMPTS_PERIOD)
->isLessThan(DateTime::createNow())
) {
$account->setIsEnabled(false);
$account->unsetData();
}
$this->entityManager->saveEntity($account, [
SaveOption::SKIP_HOOKS => true,
SaveOption::SILENT => true,
]);
if (!$account->isEnabled()) {
$this->createDisableNotification($account);
}
}
/**
* @param IClient $client
* @throws Error
*/
public function reFetchClient($client): void
{
$accountSet = $this->getClientRecord($client);
$id = $accountSet->getId();
$account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, $id);
if (!$account) {
throw new Error("External Account: Client $id not found in DB.");
}
$data = $account->getValueMap();
$accountSet->set($data);
$client->setParams(get_object_vars($data));
}
/**
* @throws Error
*/
private function fetchAccountOnlyWithIsLocked(string $id): ExternalAccount
{
$account = $this->entityManager
->getRDBRepository(ExternalAccount::ENTITY_TYPE)
->select([Attribute::ID, 'isLocked'])
->where([Attribute::ID => $id])
->findOne();
if (!$account) {
throw new Error("External Account: Client '$id' not found in DB.");
}
return $account;
}
private function createDisableNotification(ExternalAccount $account): void
{
if (!str_contains($account->getId(), '__')) {
return;
}
[$integration, $userId] = explode('__', $account->getId());
if (!$this->entityManager->getEntityById(User::ENTITY_TYPE, $userId)) {
return;
}
if (!$this->language) {
return;
}
$message = $this->language->translateLabel('externalAccountNoConnectDisabled', 'messages', 'ExternalAccount');
$message = str_replace('{integration}', $integration, $message);
$notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew();
$notification
->setType(Notification::TYPE_MESSAGE)
->setMessage($message)
->setUserId($userId);
$this->entityManager->saveEntity($notification);
}
}

View File

@@ -0,0 +1,38 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\ExternalAccount\Clients;
class Google extends OAuth2Abstract
{
protected function getPingUrl()
{
return 'https://www.googleapis.com/calendar/v3/users/me/calendarList';
}
}

View File

@@ -0,0 +1,57 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\ExternalAccount\Clients;
interface IClient
{
/**
* @param string $name
* @return mixed
*/
public function getParam($name);
/**
* @param string $name
* @param mixed $value
* @return mixed
*/
public function setParam($name, $value);
/**
* @param array<string, mixed> $params
* @return mixed
*/
public function setParams(array $params);
/**
* @return bool
*/
public function ping();
}

View File

@@ -0,0 +1,563 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\ExternalAccount\Clients;
use Espo\Core\Exceptions\Error;
use Espo\Core\Utils\DateTime as DateTimeUtil;
use Espo\Core\Utils\Json;
use Espo\Core\ExternalAccount\ClientManager;
use Espo\Core\ExternalAccount\OAuth2\Client;
use Espo\Core\Utils\Log;
use Exception;
use DateTime;
use LogicException;
abstract class OAuth2Abstract implements IClient
{
/** @var Client */
protected $client = null;
/** @var ?ClientManager */
protected $manager = null;
/** @var Log */
protected $log;
/** @var string[] */
protected $paramList = [
'endpoint',
'tokenEndpoint',
'clientId',
'clientSecret',
'tokenType',
'accessToken',
'refreshToken',
'redirectUri',
'expiresAt',
];
/** @var ?string */
protected $endpoint = null;
/**
* @noinspection PhpUnused
* @var ?string
*/
protected $tokenEndpoint = null;
/**
* @noinspection PhpUnused
* @var ?string
*/
protected $redirectUri = null;
/** @var ?string */
protected $clientId = null;
/** @var ?string */
protected $clientSecret = null;
/**
* @noinspection PhpUnused
* @var ?string
*/
protected $tokenType = null;
/** @var ?string */
protected $accessToken = null;
/** @var ?string */
protected $refreshToken = null;
/** @var ?string */
protected $expiresAt = null;
const ACCESS_TOKEN_EXPIRATION_MARGIN = '20 seconds';
const LOCK_TIMEOUT = 5;
const LOCK_CHECK_STEP = 0.5;
/**
* @param array<string, mixed> $params
*/
public function __construct(
Client $client,
array $params = [],
?ClientManager $manager = null,
?Log $log = null
) {
$this->client = $client;
$this->manager = $manager;
$this->log = $log ?? $GLOBALS['log'];
$this->setParams($params);
}
/**
* @param string $name
* @return mixed
*/
public function getParam($name)
{
if (in_array($name, $this->paramList)) {
return $this->$name;
}
return null;
}
/**
* @param string $name
* @param mixed $value
* @return void
*/
public function setParam($name, $value)
{
if (in_array($name, $this->paramList)) {
$methodName = 'set' . ucfirst($name);
if (method_exists($this->client, $methodName)) {
$this->client->$methodName($value);
}
$this->$name = $value;
}
}
/**
* @param array<string, mixed> $params
* @return void
*/
public function setParams(array $params)
{
foreach ($this->paramList as $name) {
if (array_key_exists($name, $params)) {
$this->setParam($name, $params[$name]);
}
}
}
/**
* @param array{
* accessToken: ?string,
* tokenType: ?string,
* expiresAt?: ?string,
* refreshToken?: ?string
* } $data
* @return void
* @throws Error
*/
protected function afterTokenRefreshed(array $data): void
{
$this->manager?->storeAccessToken($this, $data);
}
/**
* @param array<string, mixed> $result
* @return array{
* accessToken: ?string,
* tokenType: ?string,
* refreshToken: ?string,
* expiresAt: ?string,
* }
*/
protected function getAccessTokenDataFromResponseResult($result): array
{
$data = [];
$data['accessToken'] = $result['access_token'] ?? null;
$data['tokenType'] = $result['token_type'] ?? null;
$data['expiresAt'] = null;
if (isset($result['refresh_token']) && $result['refresh_token'] !== $this->refreshToken) {
$data['refreshToken'] = $result['refresh_token'];
}
if (isset($result['expires_in']) && is_numeric($result['expires_in'])) {
$data['expiresAt'] = (new DateTime())
->modify('+' . $result['expires_in'] . ' seconds')
->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
}
/**
* @var array{
* accessToken: ?string,
* tokenType: ?string,
* refreshToken: ?string,
* expiresAt: ?string,
* }
*/
return $data;
}
/**
* @return ?array{
* accessToken: ?string,
* tokenType: ?string,
* expiresAt: ?string,
* refreshToken: ?string,
* }
* @throws Exception
*/
public function getAccessTokenFromAuthorizationCode(string $code)
{
$response = $this->client->getAccessToken(
$this->getParam('tokenEndpoint'),
Client::GRANT_TYPE_AUTHORIZATION_CODE,
[
'code' => $code,
'redirect_uri' => $this->getParam('redirectUri'),
]
);
if ($response['code'] != 200) {
$this->log->debug("OAuth getAccessTokenFromAuthorizationCode; Response: " . Json::encode($response));
return null;
}
if (empty($response['result'])) {
$this->log->debug("OAuth getAccessTokenFromAuthorizationCode; Response: " . Json::encode($response));
return null;
}
/** @var array<string, mixed> $result */
$result = $response['result'];
$data = $this->getAccessTokenDataFromResponseResult($result);
$data['refreshToken'] = $result['refresh_token'] ?? null;
/**
* @var array{
* accessToken: ?string,
* tokenType: ?string,
* expiresAt: ?string,
* refreshToken: ?string,
* }
*/
return $data;
}
/**
* @return string
*/
protected function getPingUrl()
{
throw new LogicException("Ping is not implemented.");
}
/**
* @return bool
*/
public function ping()
{
if (empty($this->accessToken) || empty($this->clientId) || empty($this->clientSecret)) {
return false;
}
$url = $this->getPingUrl();
try {
$this->request($url);
return true;
} catch (Exception) {
return false;
}
}
/**
* @return void
* @throws Error
*/
public function handleAccessTokenActuality()
{
if (!$this->getParam('expiresAt')) {
return;
}
try {
$dt = new DateTime($this->getParam('expiresAt'));
} catch (Exception) {
$this->log->debug("Oauth: Bad expires-at parameter stored for client $this->clientId.");
return;
}
$dt->modify('-' . $this::ACCESS_TOKEN_EXPIRATION_MARGIN);
if ($dt->format('U') > (new DateTime())->format('U')) {
return;
}
$this->log->debug("Oauth: Refreshing expired token for client $this->clientId.");
if (!$this->isLocked()) {
$this->refreshToken();
return;
}
$until = microtime(true) + $this::LOCK_TIMEOUT;
while (true) {
usleep($this::LOCK_CHECK_STEP * 1000000);
if (!$this->isLocked()) {
$this->log->debug("Oauth: Waited until unlocked for client $this->clientId.");
$this->reFetch();
return;
}
if (microtime(true) > $until) {
$this->log->debug("Oauth: Waited until unlocked but timed out for client $this->clientId.");
$this->unlock();
break;
}
}
$this->refreshToken();
}
/**
* @throws Error
* @phpstan-impure
*/
protected function isLocked(): bool
{
if (!$this->manager) {
return false;
}
return $this->manager->isClientLocked($this);
}
/**
* @throws Error
*/
protected function lock(): void
{
if (!$this->manager) {
return;
}
$this->manager->lockClient($this);
}
/**
* @throws Error
*/
protected function unlock(): void
{
if (!$this->manager) {
return;
}
$this->manager->unlockClient($this);
}
/**
* @throws Error
*/
private function controlRefreshTokenAttempts(): void
{
if (!$this->manager) {
return;
}
$this->manager->controlRefreshTokenAttempts($this);
}
/**
* @throws Error
*/
protected function reFetch(): void
{
if (!$this->manager) {
return;
}
$this->manager->reFetchClient($this);
}
/**
* @param string $url
* @param array<string, mixed>|string|null $params
* @param string $httpMethod
* @param ?string $contentType
* @param bool $allowRenew
* @return mixed
* @throws Error
*/
public function request(
$url,
$params = null,
$httpMethod = Client::HTTP_METHOD_GET,
$contentType = null,
$allowRenew = true
) {
$this->handleAccessTokenActuality();
$httpHeaders = [];
if (!empty($contentType)) {
$httpHeaders['Content-Type'] = $contentType;
switch ($contentType) {
case Client::CONTENT_TYPE_APPLICATION_JSON:
case Client::CONTENT_TYPE_MULTIPART_FORM_DATA:
if (is_string($params)) {
$httpHeaders['Content-Length'] = (string) strlen($params);
}
break;
}
}
try {
$response = $this->client->request($url, $params, $httpMethod, $httpHeaders);
} catch (Exception $e) {
throw new Error($e->getMessage(), 0, $e);
}
$code = null;
if (!empty($response['code'])) {
$code = $response['code'];
}
$result = $response['result'];
if ($code >= 200 && $code < 300) {
return $result;
}
$handledData = $this->handleErrorResponse($response);
if ($allowRenew && is_array($handledData)) {
if ($handledData['action'] === 'refreshToken') {
if ($this->refreshToken()) {
return $this->request($url, $params, $httpMethod, $contentType, false);
}
} else if ($handledData['action'] === 'renew') {
return $this->request($url, $params, $httpMethod, $contentType, false);
}
}
$reasonPart = '';
if (
is_array($result) &&
isset($result['error']['message'])
) {
$reasonPart = '; Reason: ' . $result['error']['message'];
}
$this->log->debug("OAuth response: " . Json::encode($response));
throw new Error("Oauth: Error after requesting $httpMethod $url$reasonPart.", (int) $code);
}
/**
* @return bool
* @throws Error
*/
protected function refreshToken()
{
if (empty($this->refreshToken)) {
throw new Error(
"Oauth: Could not refresh token for client $this->clientId, because refreshToken is empty.");
}
$this->lock();
assert(is_string($this->refreshToken));
try {
$response = $this->client->getAccessToken(
$this->getParam('tokenEndpoint'),
Client::GRANT_TYPE_REFRESH_TOKEN,
['refresh_token' => $this->refreshToken]
);
} catch (Exception $e) {
$this->unlock();
$this->controlRefreshTokenAttempts();
throw new Error("Oauth: Error while refreshing token: " . $e->getMessage());
}
if ($response['code'] == 200) {
if (is_array($response['result']) && !empty($response['result']['access_token'])) {
$data = $this->getAccessTokenDataFromResponseResult($response['result']);
$this->setParams($data);
$this->afterTokenRefreshed($data);
$this->unlock();
return true;
}
}
$this->unlock();
$this->controlRefreshTokenAttempts();
$this->log->error("Oauth: Refreshing token failed for client $this->clientId: " . json_encode($response));
return false;
}
/**
* @param array<string, mixed> $response
* @return ?array{
* action: string,
* }
*/
protected function handleErrorResponse($response)
{
if ($response['code'] == 401 && !empty($response['result'])) {
if (str_contains($response['header'], 'error=invalid_token')) {
return ['action' => 'refreshToken'];
}
return ['action' => 'renew'];
}
if ($response['code'] == 400) {
$result = $response['result'] ?? null;
if (is_array($result)) {
$error = $result['error'] ?? null;
if ($error === 'invalid_token') {
return ['action' => 'refreshToken'];
}
}
}
return null;
}
}

View File

@@ -0,0 +1,442 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\ExternalAccount\OAuth2;
use Exception;
use RuntimeException;
use LogicException;
class Client
{
const AUTH_TYPE_URI = 0;
const AUTH_TYPE_AUTHORIZATION_BASIC = 1;
const AUTH_TYPE_FORM = 2;
const TOKEN_TYPE_URI = 'Uri';
const TOKEN_TYPE_BEARER = 'Bearer';
const TOKEN_TYPE_OAUTH = 'OAuth';
/**
* @noinspection PhpUnused
* @noinspection SpellCheckingInspection
*/
const CONTENT_TYPE_APPLICATION_X_WWW_FORM_URLENENCODED = 'application/x-www-form-urlencoded';
const CONTENT_TYPE_MULTIPART_FORM_DATA = 'multipart/form-data';
const CONTENT_TYPE_APPLICATION_JSON = 'application/json';
const HTTP_METHOD_GET = 'GET';
const HTTP_METHOD_POST = 'POST';
const HTTP_METHOD_PUT = 'PUT';
const HTTP_METHOD_DELETE = 'DELETE';
const HTTP_METHOD_HEAD = 'HEAD';
const HTTP_METHOD_PATCH = 'PATCH';
const GRANT_TYPE_AUTHORIZATION_CODE = 'authorization_code';
const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token';
/** @noinspection PhpUnused */
const GRANT_TYPE_PASSWORD = 'password';
/** @noinspection PhpUnused */
const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials';
private const REFRESH_TOKEN_TIMEOUT = 10;
private const DEFAULT_TIMEOUT = 3600 * 2;
/** @var ?string */
protected $clientId = null;
/** @var ?string */
protected $clientSecret = null;
/** @var ?string */
protected $accessToken = null;
/** @var ?string */
protected $expiresAt = null;
/** @var int */
protected $authType = self::AUTH_TYPE_URI;
/** @var string */
protected $tokenType = self::TOKEN_TYPE_URI;
/** @var ?string */
protected $accessTokenSecret = null;
/** @var string */
protected $accessTokenParamName = 'access_token';
/** @var ?string */
protected $certificateFile = null;
/** @var array<string, mixed> */
protected $curlOptions = [];
public function __construct()
{
if (!extension_loaded('curl')) {
throw new RuntimeException('CURL extension not found.');
}
}
/**
* @param string $clientId
* @return void
* @noinspection PhpUnused
*/
public function setClientId($clientId)
{
$this->clientId = $clientId;
}
/**
* @param ?string $clientSecret
* @return void
* @noinspection PhpUnused
*/
public function setClientSecret($clientSecret)
{
$this->clientSecret = $clientSecret;
}
/**
* @param ?string $accessToken
* @return void
* @noinspection PhpUnused
*/
public function setAccessToken($accessToken)
{
$this->accessToken = $accessToken;
}
/**
* @param int $authType
* @return void
* @noinspection PhpUnused
*/
public function setAuthType($authType)
{
$this->authType = $authType;
}
/**
* @param string $certificateFile
* @return void
* @noinspection PhpUnused
*/
public function setCertificateFile($certificateFile)
{
$this->certificateFile = $certificateFile;
}
/**
* @param string $option
* @param mixed $value
* @return void
* @noinspection PhpUnused
*/
public function setCurlOption($option, $value)
{
$this->curlOptions[$option] = $value;
}
/**
* @param array<string, mixed> $options
* @return void
* @noinspection PhpUnused
*/
public function setCurlOptions($options)
{
$this->curlOptions = array_merge($this->curlOptions, $options);
}
/**
* @param string $tokenType
* @return void
* @noinspection PhpUnused
*/
public function setTokenType($tokenType)
{
$lower = strtolower($tokenType);
if ($lower === 'bearer') {
$tokenType = self::TOKEN_TYPE_BEARER;
}
if ($lower === 'uri') {
$tokenType = self::TOKEN_TYPE_URI;
}
if ($lower === 'oauth') {
$tokenType = self::TOKEN_TYPE_OAUTH;
}
$this->tokenType = $tokenType;
}
/**
* @param ?string $value
* @return void
* @noinspection PhpUnused
*/
public function setExpiresAt($value)
{
$this->expiresAt = $value;
}
/**
* @param ?string $accessTokenSecret
* @return void
* @noinspection PhpUnused
*/
public function setAccessTokenSecret($accessTokenSecret)
{
$this->accessTokenSecret = $accessTokenSecret;
}
/**
* @param string $url
* @param array<string, mixed>|string|null $params
* @param string $httpMethod
* @param array<string, string> $httpHeaders
* @return array{
* result: array<string, mixed>|string,
* code: int,
* contentType: string|false,
* header: string,
* }
* @throws Exception
*/
public function request(
$url,
$params = null,
$httpMethod = self::HTTP_METHOD_GET,
array $httpHeaders = []
) {
if ($this->accessToken) {
switch ($this->tokenType) {
case self::TOKEN_TYPE_URI:
if (is_string($params) || $params === null) {
$params = [];
}
$params[$this->accessTokenParamName] = $this->accessToken;
break;
case self::TOKEN_TYPE_BEARER:
$httpHeaders['Authorization'] = 'Bearer ' . $this->accessToken;
break;
case self::TOKEN_TYPE_OAUTH:
$httpHeaders['Authorization'] = 'OAuth ' . $this->accessToken;
break;
default:
throw new Exception('Unknown access token type.');
}
}
return $this->execute($url, $params, $httpMethod, $httpHeaders);
}
/**
* @param string $url
* @param array<string, mixed>|string|null $params
* @param string $httpMethod
* @param array<string, string> $httpHeaders
* @return array{
* result: array<string, mixed>|string,
* code: int,
* contentType: string|false,
* header: string,
* }
* @throws Exception
*/
private function execute(
$url,
$params,
$httpMethod,
array $httpHeaders = [],
?int $timeout = null
) {
$curlOptions = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_CUSTOMREQUEST => $httpMethod,
CURLOPT_TIMEOUT => $timeout ?: self::DEFAULT_TIMEOUT,
];
switch ($httpMethod) {
/** @noinspection PhpMissingBreakStatementInspection */
case self::HTTP_METHOD_POST:
$curlOptions[CURLOPT_POST] = true;
case self::HTTP_METHOD_PUT:
case self::HTTP_METHOD_PATCH:
if (is_array($params)) {
$postFields = http_build_query($params, '', '&');
} else {
$postFields = $params;
}
$curlOptions[CURLOPT_POSTFIELDS] = $postFields;
break;
/** @noinspection PhpMissingBreakStatementInspection */
case self::HTTP_METHOD_HEAD:
$curlOptions[CURLOPT_NOBODY] = true;
case self::HTTP_METHOD_DELETE:
case self::HTTP_METHOD_GET:
if (!str_contains($url, '?')) {
$url .= '?';
}
if (is_array($params)) {
$url .= http_build_query($params, '', '&');
}
break;
default:
break;
}
$curlOptions[CURLOPT_URL] = $url;
$curlOptHttpHeader = [];
foreach ($httpHeaders as $key => $value) {
if (is_int($key)) {
$curlOptHttpHeader[] = $value;
continue;
}
$curlOptHttpHeader[] = "$key: $value";
}
$curlOptions[CURLOPT_HTTPHEADER] = $curlOptHttpHeader;
$ch = curl_init();
curl_setopt_array($ch, $curlOptions);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
if (!empty($this->certificateFile)) {
curl_setopt($ch, CURLOPT_CAINFO, $this->certificateFile);
}
if (!empty($this->curlOptions)) {
curl_setopt_array($ch, $this->curlOptions);
}
/** @var string|false $response */
$response = curl_exec($ch);
if ($response === false) {
throw new Exception("Curl failure.");
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$responseHeader = substr($response, 0, $headerSize);
$responseBody = substr($response, $headerSize);
if ($curlError = curl_error($ch)) {
throw new Exception($curlError);
}
$resultArray = json_decode($responseBody, true);
curl_close($ch);
/** @var array<string, mixed>|string $result */
$result = ($resultArray !== null) ?
$resultArray :
$responseBody;
return [
'result' => $result,
'code' => intval($httpCode),
'contentType' => $contentType,
'header' => $responseHeader,
];
}
/**
* @param string $url
* @param string $grantType
* @param array{
* client_id?: string,
* client_secret?: string,
* redirect_uri?: string,
* code?: string,
* refresh_token?: string,
* } $params
* @return array{
* result: array<string, mixed>|string,
* code: int,
* contentType: string|false,
* header: string,
* }
* @throws Exception
*/
public function getAccessToken($url, $grantType, array $params)
{
$params['grant_type'] = $grantType;
$httpHeaders = [];
switch ($this->authType) {
case self::AUTH_TYPE_URI:
case self::AUTH_TYPE_FORM:
$params['client_id'] = $this->clientId;
$params['client_secret'] = $this->clientSecret;
break;
case self::AUTH_TYPE_AUTHORIZATION_BASIC:
$params['client_id'] = $this->clientId;
$httpHeaders['Authorization'] = 'Basic ' . base64_encode($this->clientId . ':' . $this->clientSecret);
break;
default:
throw new LogicException("Bad auth type.");
}
return $this->execute($url, $params, self::HTTP_METHOD_POST, $httpHeaders, self::REFRESH_TOKEN_TIMEOUT);
}
}