snapshot of https://github.com/espocrm/espocrm.git (master, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Test / Test on PHP 8.3 (push) Failing after 5s
Test / Test on PHP 8.4 (push) Failing after 4s
Test / Test on PHP 8.5 (push) Failing after 3s
TypeScript static analysis / TypeScript check (push) Failing after 14s
Test Integration on MySQL / Test on PHP 8.3 (push) Failing after 1s
Test Integration on MySQL / Test on PHP 8.5 (push) Failing after 1s
Test Integration on PostgreSQL / Test on PHP 8.4 fix (push) Failing after 1s
Test Integration on PostgreSQL / Test on PHP 8.4 master (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-18 04:19:15 +05:00
commit 7cc7e6b4f3
10077 changed files with 900756 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Api\AccessToken;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Record\EntityProvider;
use Espo\Tools\OAuthServer\Entities\AccessToken;
use Espo\Tools\OAuthServer\Record\TokenService;
/**
* @noinspection PhpUnused
*/
class PostRevoke implements Action
{
public function __construct(
private EntityProvider $entityProvider,
private TokenService $service,
) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id') ?? throw new BadRequest();
$entity = $this->entityProvider->getByClass(AccessToken::class, $id);
$this->service->revoke($entity);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,61 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Api\AuthorizationCode;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Record\EntityProvider;
use Espo\Tools\OAuthServer\Entities\AuthorizationCode;
use Espo\Tools\OAuthServer\Record\TokenService;
/**
* @noinspection PhpUnused
*/
class PostRevoke implements Action
{
public function __construct(
private EntityProvider $entityProvider,
private TokenService $service,
) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id') ?? throw new BadRequest();
$entity = $this->entityProvider->getByClass(AuthorizationCode::class, $id);
$this->service->revoke($entity);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,56 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Tools\OAuthServer\ConsentDataService;
/**
* @noinspection PhpUnused
*/
class GetConsentData implements Action
{
public function __construct(
private ConsentDataService $service,
) {}
public function process(Request $request): Response
{
$clientId = $request->getQueryParam('clientId') ?? throw new BadRequest("No clientId.");
$data = $this->service->getData($clientId);
return ResponseComposer::json($data);
}
}

View File

@@ -0,0 +1,61 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Api\RefreshToken;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Record\EntityProvider;
use Espo\Tools\OAuthServer\Entities\RefreshToken;
use Espo\Tools\OAuthServer\Record\TokenService;
/**
* @noinspection PhpUnused
*/
class PostRevoke implements Action
{
public function __construct(
private EntityProvider $entityProvider,
private TokenService $service,
) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id') ?? throw new BadRequest();
$entity = $this->entityProvider->getByClass(RefreshToken::class, $id);
$this->service->revoke($entity);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,185 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer;
use Espo\Core\ApplicationState;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Entities\User;
use Espo\Tools\OAuthServer\League\AuthorizationRequestStorage;
use Espo\Tools\OAuthServer\League\AuthorizationServerFactory;
use Espo\Tools\OAuthServer\League\Entities\ScopeEntity;
use Espo\Tools\OAuthServer\League\Entities\UserEntity;
use Espo\Tools\OAuthServer\Scope\ScopeSorter;
use Espo\Tools\OAuthServer\Utils\UriUtil;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class AuthorizationService
{
public function __construct(
private AuthorizationServerFactory $authorizationServerFactory,
private ApplicationState $applicationState,
private AuthorizationRequestStorage $authorizationRequestStorage,
private ScopeSorter $scopeSorter,
) {}
/**
* @throws Error
*/
public function authorizeStart(ServerRequestInterface $request, ResponseInterface $response): ?ResponseInterface
{
$server = $this->authorizationServerFactory->create();
try {
$authRequest = $server->validateAuthorizationRequest($request);
} catch (OAuthServerException $e) {
return $e->generateHttpResponse($response);
}
$clientId = $authRequest->getClient()->getIdentifier();
$this->authorizationRequestStorage->store($clientId, $authRequest);
return null;
}
/**
* @param non-empty-string[] $scopes
* @throws NotFound
* @throws Error
* @throws Forbidden
* @throws BadRequest
* @noinspection PhpRedundantCatchClauseInspection
*/
public function authorizeComplete(
string $clientId,
ResponseInterface $response,
bool $approved,
array $scopes,
): ResponseInterface {
$scopes = $this->scopeSorter->sort($scopes);
$authorizationRequest = $this->getAuthRequestFromSession($clientId);
$this->validateScopesOnCompletion($authorizationRequest, $scopes);
$authorizationRequest->setScopes(array_map(fn ($it) => new ScopeEntity($it), $scopes));
$user = $this->applicationState->getUser();
try {
$this->assertUser($user, $authorizationRequest);
} catch (OAuthServerException $e) {
return $e->generateHttpResponse($response);
}
$authorizationRequest->setUser(new UserEntity($user));
$authorizationRequest->setAuthorizationApproved($approved);
$server = $this->authorizationServerFactory->create();
try {
return $server->completeAuthorizationRequest($authorizationRequest, $response);
} catch (OAuthServerException $e) {
return $e->generateHttpResponse($response);
}
}
/**
* @throws Error
*/
public function token(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$server = $this->authorizationServerFactory->create();
try {
return $server->respondToAccessTokenRequest($request, $response);
} catch (OAuthServerException $e) {
return $e->generateHttpResponse($response);
}
}
/**
* @throws NotFound
*/
private function getAuthRequestFromSession(string $clientId): AuthorizationRequest
{
$authRequest = $this->authorizationRequestStorage->get($clientId, clear: true);
if (!$authRequest) {
throw new NotFound("Session not found.");
}
return $authRequest;
}
/**
* @throws Error
* @throws OAuthServerException
*/
private function assertUser(User $user, AuthorizationRequest $authorizationRequest): void
{
if ($user->isRegular() || $user->getType() === User::TYPE_ADMIN) {
return;
}
$redirectUri = $authorizationRequest->getRedirectUri();
if ($redirectUri && $authorizationRequest->getState()) {
$redirectUri = UriUtil::makeRedirectUri($redirectUri, $authorizationRequest->getState());
}
throw OAuthServerException::accessDenied("User is not allowed.", $redirectUri);
}
/**
* @param string[] $scopes
* @throws Forbidden
* @throws BadRequest
*/
private function validateScopesOnCompletion(AuthorizationRequest $authorizationRequest, array $scopes): void
{
if (count(array_unique($scopes)) !== count($scopes)) {
throw new BadRequest("Duplicate scopes.");
}
$storedScopes = array_map(fn ($it) => $it->getIdentifier(), $authorizationRequest->getScopes());
if (array_diff($scopes, $storedScopes)) {
throw new Forbidden("Scope mismatch.");
}
}
}

View File

@@ -0,0 +1,165 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Cleanup;
use Espo\Core\Cleanup\Cleanup;
use Espo\Core\Name\Field;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\DateTime;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Condition as Cond;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Query\SelectBuilder;
use Espo\Tools\OAuthServer\Entities\AccessToken;
use Espo\Tools\OAuthServer\Entities\AuthorizationCode;
use Espo\Tools\OAuthServer\Entities\RefreshToken;
/**
* @noinspection PhpUnused
*/
class GeneralCleanup implements Cleanup
{
private const string PERIOD_AUTHORIZATION_CODE = '15 days';
private const string PERIOD_ACCESS_TOKEN = '15 days';
private const string PERIOD_REFRESH_TOKEN = '15 days';
public function __construct(
private Config $config,
private EntityManager $entityManager,
private DateTime $dateTime,
) {}
public function process(): void
{
$this->processAuthorizationCodes();
$this->processRefreshTokens();
$this->processAccessTokens();
}
private function getAuthorizationCodePeriod(): string
{
return $this->config->get('oAuthServer.cleanup.authorizationCodePeriod') ?? self::PERIOD_AUTHORIZATION_CODE;
}
private function getAccessTokenPeriod(): string
{
return $this->config->get('oAuthServer.cleanup.accessTokenPeriod') ?? self::PERIOD_ACCESS_TOKEN;
}
private function getRefreshTokenPeriod(): string
{
return $this->config->get('oAuthServer.cleanup.refreshTokenPeriod') ?? self::PERIOD_REFRESH_TOKEN;
}
private function processAuthorizationCodes(): void
{
$time = $this->dateTime->getNow()
->modify('-' . $this->getAuthorizationCodePeriod());
$entries = $this->entityManager
->getRDBRepositoryByClass(AuthorizationCode::class)
->sth()
->where([
AuthorizationCode::FIELD_EXPIRES_AT . '<=' => $time->toString(),
])
->find();
foreach ($entries as $entry) {
$this->entityManager->removeEntity($entry);
}
}
private function processAccessTokens(): void
{
$time = $this->dateTime->getNow()
->modify('-' . $this->getAccessTokenPeriod());
$entries = $this->entityManager
->getRDBRepositoryByClass(AccessToken::class)
->sth()
->where([
AccessToken::FIELD_EXPIRES_AT . '<=' => $time->toString(),
])
->where(
Cond::not(
Cond::exists(
SelectBuilder::create()
->from(RefreshToken::ENTITY_TYPE, 'r')
->select(Attribute::ID)
->where([
'r.' . RefreshToken::FIELD_STATUS => RefreshToken::STATUS_ACTIVE,
])
->where(
Cond::equal(
Expr::column(lcfirst(AccessToken::ENTITY_TYPE) . '.' . Attribute::ID),
Expr::column('r.' . RefreshToken::FIELD_ACCESS_TOKEN . 'Id'),
)
)
->build()
)
)
)
->find();
foreach ($entries as $entry) {
$this->entityManager->removeEntity($entry);
}
}
private function processRefreshTokens(): void
{
$time = $this->dateTime->getNow()
->modify('-' . $this->getRefreshTokenPeriod());
$entries = $this->entityManager
->getRDBRepositoryByClass(RefreshToken::class)
->sth()
->where([
'OR' => [
[
RefreshToken::FIELD_EXPIRES_AT . '<=' => $time->toString(),
],
[
RefreshToken::FIELD_STATUS => [
RefreshToken::STATUS_REVOKED,
RefreshToken::STATUS_EXPIRED,
],
Field::MODIFIED_AT . '<=' => $time->toString(),
],
],
])
->find();
foreach ($entries as $entry) {
$this->entityManager->removeEntity($entry);
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer;
enum ClientType: string
{
case Confidential = 'Confidential';
case Public = 'Public';
}

View File

@@ -0,0 +1,50 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer;
use Espo\Tools\OAuthServer\Entities\Client;
use Espo\Tools\OAuthServer\Secret\SecretValidator;
use SensitiveParameter;
class ClientValidator
{
public function __construct(
private SecretValidator $secretValidator,
) {}
public function validate(Client $client, #[SensitiveParameter] ?string $secret): bool
{
if (!$secret) {
return $client->getClientType() === ClientType::Public;
}
return $this->secretValidator->validate($client, $secret);
}
}

View File

@@ -0,0 +1,48 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\ConnectedApp;
use stdClass;
readonly class AppData
{
public function __construct(
public string $id,
public ?string $name,
) {}
public function toApiOutput(): stdClass
{
return (object) [
'id' => $this->id,
'name' => $this->name,
];
}
}

View File

@@ -0,0 +1,96 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\ConnectedApp;
use Espo\Core\Exceptions\NotFound;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
use Espo\Tools\OAuthServer\Entities\Client;
use Espo\Tools\OAuthServer\Repository\AccessTokenRepository;
use Espo\Tools\OAuthServer\Repository\ClientRepository;
use Espo\Tools\OAuthServer\Repository\RefreshTokenRepository;
use RuntimeException;
class ConnectedAppService
{
private const int LIMIT = 50;
public function __construct(
private ClientRepository $clientRepository,
private RefreshTokenRepository $refreshTokenRepository,
private AccessTokenRepository $accessTokenRepository,
private EntityManager $entityManager,
) {}
/**
* @return AppData[]
*/
public function getList(User $user): array
{
$clients = $this->clientRepository->findConnectedForUser($user->getId(), self::LIMIT);
return array_map(function (Client $it) {
$id = $it->getIdentifier() ?? throw new RuntimeException();
return new AppData(
id: $id,
name: $it->getName() ?? $id,
);
}, iterator_to_array($clients));
}
/**
* @throws NotFound
*/
public function disconnect(User $user, string $clientId): void
{
$client = $this->clientRepository->getByIdentifier($clientId);
if (!$client) {
throw new NotFound("Client not found.");
}
$refreshTokens = $this->refreshTokenRepository->findActiveForClientIdAndUser($client->getId(), $user->getId());
foreach ($refreshTokens as $refreshToken) {
$refreshToken->setRevoked();
$this->entityManager->saveEntity($refreshToken);
}
$accessTokens = $this->accessTokenRepository->findActiveForClientIdAndUser($client->getId(), $user->getId());
foreach ($accessTokens as $accessToken) {
$accessToken->setRevoked();
$this->entityManager->saveEntity($accessToken);
}
}
}

View File

@@ -0,0 +1,46 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\ConnectedApp;
use Espo\Core\Exceptions\Forbidden;
use Espo\Entities\User;
class UserCheck
{
/**
* @throws Forbidden
*/
public function assert(User $user): void
{
if ($user->getType() !== User::TYPE_ADMIN && !$user->isRegular()) {
throw new Forbidden("Only regular users and admins are allowed.");
}
}
}

View File

@@ -0,0 +1,165 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer;
use Espo\Core\Acl\Scope;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Utils\Config\ApplicationConfig;
use Espo\Core\Utils\Language;
use Espo\Entities\User;
use Espo\Tools\OAuthServer\Entities\Client;
use Espo\Tools\OAuthServer\League\AuthorizationRequestStorage;
use Espo\Tools\OAuthServer\Repository\ClientRepository;
use Espo\Tools\OAuthServer\Scope\ScopeSorter;
use Espo\Tools\OAuthServer\Scope\UserAvailableScopesFilter;
use InvalidArgumentException;
use stdClass;
class ConsentDataService
{
public function __construct(
private ClientRepository $clientRepository,
private User $user,
private Language $language,
private ApplicationConfig $applicationConfig,
private AuthorizationRequestStorage $authorizationRequestStorage,
private UserAvailableScopesFilter $userAvailableScopesFilter,
private ScopeSorter $scopeSorter,
) {}
/**
* @throws NotFound
* @throws Error
*/
public function getData(string $clientId): stdClass
{
$client = $this->getClient($clientId);
return (object) [
'scopeDataList' => $this->getScopeDataList($client),
'labels' => (object) [
'allow' => $this->language->translateLabel('allow', 'strings', Client::ENTITY_TYPE),
'cancel' => $this->language->translateLabel('Cancel'),
'info' => $this->composeInfoText($client),
],
];
}
/**
* @throws NotFound
*/
private function getClient(string $clientId): Entities\Client
{
$client = $this->clientRepository->getActiveByIdentifier($clientId);
if (!$client) {
throw new NotFound("Client not found.");
}
return $client;
}
private function translateScope(string $scope): string
{
$appScope = $scope;
$actionLabel = null;
if (str_contains($scope, ':')) {
[$appScope, $action] = explode(':', $appScope, 2);
$actionLabel = $this->language->translateOption($action, 'levelList', 'Role');
}
$scopeLabel = $this->language->translateLabel($appScope, 'scopeNames');
$label = $scopeLabel;
if ($actionLabel) {
$label .= ' . ' . $actionLabel;
}
return $label;
}
private function composeInfoText(Client $client): string
{
return strtr($this->language->translateLabel('allowAccessInfo', 'messages', Client::ENTITY_TYPE), [
'{clientName}' => $client->getName(),
'{applicationName}' => $this->applicationConfig->getApplicationName(),
'{username}' => $this->user->getUserName(),
]);
}
/**
* @return stdClass[]
* @throws Error
*/
private function getScopeDataList(Client $client): array
{
$clientId = $client->getIdentifier() ?? throw new InvalidArgumentException("No client identifier.");
$request = $this->authorizationRequestStorage->get($clientId);
if (!$request) {
throw new Error("No session data.");
}
$scopes = array_map(fn ($it) => $it->getIdentifier(), $request->getScopes());
$scopes = $this->userAvailableScopesFilter->filter($this->user->getId(), $scopes);
/** @var (stdClass & object{name: string, label: string})[] $scopeDataList */
$scopeDataList = array_map(function ($scope) {
return (object) [
'name' => $scope,
'label' => $this->translateScope($scope),
'description' => $this->getScopeDescription($scope),
];
}, $scopes);
return $this->scopeSorter->sortDataItems($scopeDataList);
}
private function getScopeDescription(string $scope): ?string
{
if (!in_array($scope, [Scope::ADMIN, Scope::GLOBAL])) {
return null;
}
$key = $scope;
if ($scope === Scope::GLOBAL && $this->user->isAdmin()) {
$key = 'GlobalAdmin';
}
return $this->language->translateLabel($key, 'scopeDescriptions', Client::ENTITY_TYPE);
}
}

View File

@@ -0,0 +1,163 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Entities;
use Espo\Core\Field\DateTime;
use Espo\Core\Field\Link;
use Espo\Core\ORM\Entity;
use Espo\Entities\User;
use SensitiveParameter;
use UnexpectedValueException;
class AccessToken extends Entity
{
public const string ENTITY_TYPE = 'OAuthAccessToken';
public const string FIELD_STATUS = 'status';
public const string FIELD_CLIENT = 'client';
public const string FIELD_IDENTIFIER = 'identifier';
public const string FIELD_EXPIRES_AT = 'expiresAt';
public const string FIELD_SCOPES = 'scopes';
public const string FIELD_USER = 'user';
public const string FIELD_HASH = 'hash';
public const string STATUS_ACTIVE = 'Active';
public const string STATUS_REVOKED = 'Revoked';
public const string STATUS_EXPIRED = 'Expired';
public function isActive(): bool
{
return $this->get(self::FIELD_STATUS) === self::STATUS_ACTIVE;
}
public function isRevoked(): bool
{
return $this->get(self::FIELD_STATUS) === self::STATUS_REVOKED;
}
public function getClient(): Client
{
$client = $this->relations->getOne(self::FIELD_CLIENT);
if (!$client instanceof Client) {
throw new UnexpectedValueException("No client.");
}
return $client;
}
public function setClient(Client|Link $client): self
{
return $this->setRelatedLinkOrEntity(self::FIELD_CLIENT, $client);
}
public function setRevoked(): self
{
return $this->set(self::FIELD_STATUS, self::STATUS_REVOKED);
}
public function setExpired(): self
{
return $this->set(self::FIELD_STATUS, self::STATUS_EXPIRED);
}
public function setHash(string $hash): self
{
return $this->set(self::FIELD_HASH, $hash);
}
public function setIdentifier(#[SensitiveParameter] string $identifier): self
{
return $this->set(self::FIELD_IDENTIFIER, $identifier);
}
/**
* @return non-empty-string
*/
public function getIdentifier(): string
{
/** @var non-empty-string */
return $this->get(self::FIELD_IDENTIFIER) ?? throw new UnexpectedValueException("No identifier.");
}
public function getExpiresAt(): DateTime
{
$value = $this->getValueObject(self::FIELD_EXPIRES_AT);
if (!$value instanceof DateTime) {
throw new UnexpectedValueException("No expiresAt.");
}
return $value;
}
public function setExpiresAt(DateTime $expiresAt): self
{
return $this->setValueObject(self::FIELD_EXPIRES_AT, $expiresAt);
}
public function isExpired(): bool
{
return $this->get(self::FIELD_STATUS) === self::STATUS_EXPIRED;
}
/**
* @return non-empty-string[]
*/
public function getScopes(): array
{
/** @var non-empty-string[] */
return $this->get(self::FIELD_SCOPES) ?? [];
}
/**
* @param string[] $scopes
*/
public function setScopes(array $scopes): self
{
return $this->set(self::FIELD_SCOPES, $scopes);
}
public function getUser(): Link
{
$user = $this->getValueObject(self::FIELD_USER);
if (!$user instanceof Link) {
throw new UnexpectedValueException("No user.");
}
return $user;
}
public function setUser(Link|User $user): self
{
return $this->setRelatedLinkOrEntity(self::FIELD_USER, $user);
}
}

View File

@@ -0,0 +1,157 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Entities;
use Espo\Core\Field\DateTime;
use Espo\Core\Field\Link;
use Espo\Core\ORM\Entity;
use Espo\Entities\User;
use UnexpectedValueException;
class AuthorizationCode extends Entity
{
public const string ENTITY_TYPE = 'OAuthAuthorizationCode';
public const string FIELD_STATUS = 'status';
public const string FILED_CLIENT = 'client';
public const string FIELD_HASH = 'hash';
public const string FIELD_EXPIRES_AT = 'expiresAt';
public const string FIELD_REDIRECT_URI = 'redirectUri';
public const string FIELD_SCOPES = 'scopes';
public const string FIELD_USER = 'user';
public const string STATUS_ACTIVE = 'Active';
public const string STATUS_REVOKED = 'Revoked';
public const string STATUS_EXPIRED = 'Expired';
public function isActive(): bool
{
return $this->get(self::FIELD_STATUS) === self::STATUS_ACTIVE;
}
public function isRevoked(): bool
{
return $this->get(self::FIELD_STATUS) === self::STATUS_REVOKED;
}
public function getClient(): Client
{
$client = $this->relations->getOne(self::FILED_CLIENT);
if (!$client instanceof Client) {
throw new UnexpectedValueException("No client.");
}
return $client;
}
public function setClient(Client|Link $client): self
{
return $this->setRelatedLinkOrEntity(self::FILED_CLIENT, $client);
}
public function setRevoked(): self
{
return $this->set(self::FIELD_STATUS, self::STATUS_REVOKED);
}
public function setExpired(): self
{
return $this->set(self::FIELD_STATUS, self::STATUS_EXPIRED);
}
public function setHash(string $hash): self
{
return $this->set(self::FIELD_HASH, $hash);
}
public function getExpiresAt(): DateTime
{
$value = $this->getValueObject(self::FIELD_EXPIRES_AT);
if (!$value instanceof DateTime) {
throw new UnexpectedValueException("No expiresAt.");
}
return $value;
}
public function setExpiresAt(DateTime $expiresAt): self
{
return $this->setValueObject(self::FIELD_EXPIRES_AT, $expiresAt);
}
public function getRedirectUri(): string
{
return $this->get(self::FIELD_REDIRECT_URI) ?? throw new UnexpectedValueException("No redirectUri.");
}
public function setRedirectUri(string $redirectUri): self
{
return $this->set(self::FIELD_REDIRECT_URI, $redirectUri);
}
public function isExpired(): bool
{
return $this->get(self::FIELD_STATUS) === self::STATUS_EXPIRED;
}
/**
* @return string[]
*/
public function getScopes(): array
{
return $this->get(self::FIELD_SCOPES);
}
/**
* @param string[] $scopes
*/
public function setScopes(array $scopes): self
{
return $this->set(self::FIELD_SCOPES, $scopes);
}
public function getUser(): Link
{
$user = $this->getValueObject(self::FIELD_USER);
if (!$user instanceof Link) {
throw new UnexpectedValueException("No user.");
}
return $user;
}
public function setUser(Link|User $user): self
{
return $this->setRelatedLinkOrEntity(self::FIELD_USER, $user);
}
}

View File

@@ -0,0 +1,142 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Entities;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Entity;
use Espo\Tools\OAuthServer\ClientType;
use UnexpectedValueException;
class Client extends Entity
{
public const string ENTITY_TYPE = 'OAuthClient';
public const string FIELD_STATUS = 'status';
public const string FIELD_IDENTIFIER = 'identifier';
public const string FIELD_CLIENT_TYPE = 'clientType';
public const string FIELD_SCOPES = 'scopes';
public const string FIELD_REDIRECT_URIS = 'redirectUris';
public const string LINK_SECRETS = 'secrets';
public const string STATUS_ACTIVE = 'Active';
public const string STATUS_INACTIVE = 'Inactive';
public function getName(): ?string
{
return $this->get(Field::NAME);
}
public function setName(string $name): self
{
return $this->set(Field::NAME, $name);
}
public function getClientType(): ClientType
{
$raw = $this->get(self::FIELD_CLIENT_TYPE) ?? throw new UnexpectedValueException("No client type.");
return ClientType::tryFrom($raw) ?? throw new UnexpectedValueException("Bad client type.");
}
public function setClientType(ClientType $clientType): self
{
return $this->set(self::FIELD_CLIENT_TYPE, $clientType->value);
}
/**
* @return ?non-empty-string
*/
public function getIdentifier(): ?string
{
/** @var ?non-empty-string */
return $this->get(self::FIELD_IDENTIFIER);
}
public function setIdentifier(?string $clientId): self
{
return $this->set(self::FIELD_IDENTIFIER, $clientId);
}
public function isActive(): bool
{
return $this->get(self::FIELD_STATUS) === self::STATUS_ACTIVE;
}
public function setActive(): self
{
return $this->set(self::FIELD_STATUS, self::STATUS_ACTIVE);
}
public function setInactive(): self
{
return $this->set(self::FIELD_STATUS, self::STATUS_INACTIVE);
}
/**
* @return non-empty-string[]
*/
public function getScopes(): array
{
$scopes = $this->get(self::FIELD_SCOPES) ?? [];
foreach ($scopes as $scope) {
if (!$scope) {
throw new UnexpectedValueException("Empty scope.");
}
}
return $scopes;
}
/**
* @param string[] $scopes
*/
public function setScopes(array $scopes): self
{
return $this->set(self::FIELD_SCOPES, $scopes);
}
/**
* @return string[]
*/
public function getRedirectUris(): array
{
return $this->get(self::FIELD_REDIRECT_URIS) ?? [];
}
/**
* @param string[] $redirectUris
*/
public function setRedirectUris(array $redirectUris): self
{
return $this->set(self::FIELD_REDIRECT_URIS, $redirectUris);
}
}

View File

@@ -0,0 +1,135 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Entities;
use Espo\Core\Field\Date;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Entity;
use SensitiveParameter;
use UnexpectedValueException;
class ClientSecret extends Entity
{
public const string ENTITY_TYPE = 'OAuthClientSecret';
public const string FIELD_STATUS = 'status';
public const string FIELD_HASH = 'hash';
public const string FIELD_CLIENT = 'client';
public const string FIELD_EXPIRATION_DATE = 'expirationDate';
public const string FIELD_VALUE = 'value';
public const string STATUS_ACTIVE = 'Active';
public const string STATUS_REVOKED = 'Revoked';
public const string STATUS_EXPIRED = 'Expired';
public const string ATTR_CLIENT_ID = 'clientId';
public function setName(string $name): self
{
return $this->set(Field::NAME, $name);
}
public function getName(): ?string
{
return $this->get(Field::NAME);
}
public function getHash(): string
{
return $this->get(self::FIELD_HASH) ?? throw new UnexpectedValueException("No hash.");
}
public function setHash(string $value): self
{
return $this->set(self::FIELD_HASH, $value);
}
public function isActive(): bool
{
return $this->get(self::FIELD_STATUS) === self::STATUS_ACTIVE;
}
public function isExpired(): bool
{
return $this->get(self::FIELD_STATUS) === self::STATUS_EXPIRED;
}
public function isRevoked(): bool
{
return $this->get(self::FIELD_STATUS) === self::STATUS_REVOKED;
}
public function setRevoked(): self
{
return $this->set(self::FIELD_STATUS, self::STATUS_REVOKED);
}
public function setExpired(): self
{
return $this->set(self::FIELD_STATUS, self::STATUS_EXPIRED);
}
public function getExpirationDate(): ?Date
{
/** @var ?Date */
return $this->getValueObject(self::FIELD_EXPIRATION_DATE);
}
public function setExpirationDate(?Date $date): self
{
return $this->setValueObject(self::FIELD_EXPIRATION_DATE, $date);
}
public function getClient(): Client
{
$client = $this->relations->getOne(self::FIELD_CLIENT);
if (!$client instanceof Client) {
throw new UnexpectedValueException("No client.");
}
return $client;
}
public function setClient(Client $client): self
{
return $this->setRelatedLinkOrEntity(self::FIELD_CLIENT, $client);
}
public function setValue(#[SensitiveParameter] string $value): self
{
return $this->set(self::FIELD_VALUE, $value);
}
public function getValue(): string
{
return $this->get(self::FIELD_VALUE) ?? throw new UnexpectedValueException("No value.");
}
}

View File

@@ -0,0 +1,146 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Entities;
use Espo\Core\Field\DateTime;
use Espo\Core\Field\Link;
use Espo\Core\ORM\Entity;
use Espo\Entities\User;
use UnexpectedValueException;
class RefreshToken extends Entity
{
public const string ENTITY_TYPE = 'OAuthRefreshToken';
public const string FIELD_STATUS = 'status';
public const string FIELD_CLIENT = 'client';
public const string FIELD_EXPIRES_AT = 'expiresAt';
public const string FIELD_USER = 'user';
public const string FIELD_ACCESS_TOKEN = 'accessToken';
public const string FIELD_HASH = 'hash';
public const string STATUS_ACTIVE = 'Active';
public const string STATUS_REVOKED = 'Revoked';
public const string STATUS_EXPIRED = 'Expired';
public function setHash(string $value): self
{
return $this->set(self::FIELD_HASH, $value);
}
public function isActive(): bool
{
return $this->get(self::FIELD_STATUS) === self::STATUS_ACTIVE;
}
public function isRevoked(): bool
{
return $this->get(self::FIELD_STATUS) === self::STATUS_REVOKED;
}
public function setStatus(string $status): self
{
return $this->set(self::FIELD_STATUS, $status);
}
public function getClient(): Client
{
$client = $this->relations->getOne(self::FIELD_CLIENT);
if (!$client instanceof Client) {
throw new UnexpectedValueException("No client.");
}
return $client;
}
public function setAccessToken(AccessToken $accessToken): self
{
return $this->setRelatedLinkOrEntity(self::FIELD_ACCESS_TOKEN, $accessToken);
}
public function setClient(Client $client): self
{
return $this->setRelatedLinkOrEntity(self::FIELD_CLIENT, $client);
}
public function isExpired(): bool
{
return $this->get(self::FIELD_STATUS) === self::STATUS_EXPIRED;
}
public function setRevoked(): self
{
return $this->set(self::FIELD_STATUS, self::STATUS_REVOKED);
}
public function setExpired(): self
{
return $this->set(self::FIELD_STATUS, self::STATUS_EXPIRED);
}
public function getExpiresAt(): DateTime
{
$value = $this->getValueObject(self::FIELD_EXPIRES_AT);
if (!$value instanceof DateTime) {
throw new UnexpectedValueException("No expiresAt.");
}
return $value;
}
public function setExpiresAt(DateTime $expiresAt): self
{
return $this->setValueObject(self::FIELD_EXPIRES_AT, $expiresAt);
}
public function getUser(): Link
{
$user = $this->getValueObject(self::FIELD_USER);
if (!$user instanceof Link) {
throw new UnexpectedValueException("No user.");
}
return $user;
}
public function setUser(Link|User $user): self
{
return $this->setRelatedLinkOrEntity(self::FIELD_USER, $user);
}
public function getAccessToken(): ?AccessToken
{
/** @var ?AccessToken */
return $this->relations->getOne(self::FIELD_ACCESS_TOKEN);
}
}

View File

@@ -0,0 +1,53 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\EntryPoints;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\EntryPoint\EntryPoint;
use Espo\Core\Utils\Json;
use Espo\Tools\OAuthServer\Metadata\AuthorizationServerMetadataProvider;
/**
* @noinspection PhpUnused
*/
class AuthorizationServerMetadata implements EntryPoint
{
public function __construct(
private AuthorizationServerMetadataProvider $provider,
) {}
public function run(Request $request, Response $response): void
{
$data = $this->provider->get();
$response->writeBody(Json::encode($data, JSON_PRETTY_PRINT));
}
}

View File

@@ -0,0 +1,74 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\EntryPoints;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\EntryPoint\EntryPoint;
use Espo\Core\Utils\Client\ActionRenderer;
use Espo\Core\Utils\Client\SecurityParams;
use Espo\Tools\OAuthServer\AuthorizationService;
/**
* @noinspection PhpUnused
*/
class Authorize implements EntryPoint
{
public function __construct(
private ActionRenderer $actionRenderer,
private AuthorizationService $service,
) {}
public function run(Request $request, Response $response): void
{
$psr7Response = $this->service->authorizeStart($request->toPsr7(), $response->toPsr7());
if ($psr7Response) {
$response->applyPsr7($psr7Response);
return;
}
$params = new ActionRenderer\Params(
controller: 'controllers/o-auth-authorize',
action: 'show',
);
$params = $params
->withLogin()
->withSecurityParams(
new SecurityParams(
formAction: [],
)
);
$this->actionRenderer->write($response, $params);
}
}

View File

@@ -0,0 +1,93 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\EntryPoints;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\EntryPoint\EntryPoint;
use Espo\Core\Exceptions\BadRequest;
use Espo\Tools\OAuthServer\AuthorizationService;
/**
* @noinspection PhpUnused
*/
class AuthorizeComplete implements EntryPoint
{
public function __construct(
private AuthorizationService $service,
) {}
public function run(Request $request, Response $response): void
{
$body = $request->getParsedBody();
$clientId = $body->clientId ?? throw new BadRequest("No 'clientId'.");
$approved = ($body->approved ?? null) === 'true';
if (!is_string($clientId)) {
throw new BadRequest();
}
$scopes = $this->fetchScopes($request);
$psr7Response = $this->service->authorizeComplete(
clientId: $clientId,
response: $response->toPsr7(),
approved: $approved,
scopes: $scopes,
);
$response->applyPsr7($psr7Response);
}
/**
* @return non-empty-string[]
* @throws BadRequest
*/
private function fetchScopes(Request $request): array
{
$scopesRaw = $request->getParsedBody()->scopes ?? throw new BadRequest("No 'scopes'.");
$scopes = explode(' ', $scopesRaw);
foreach ($scopes as $scope) {
if (!$scope) {
throw new BadRequest("Bad 'scopes' item values.");
}
if ($scope !== trim($scope)) {
throw new BadRequest("Bad scope.");
}
}
/** @var non-empty-string[] */
return $scopes;
}
}

View File

@@ -0,0 +1,53 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\EntryPoints;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\EntryPoint\EntryPoint;
use Espo\Core\Utils\Json;
use Espo\Tools\OAuthServer\Metadata\ProtectedResourceMetadataProvider;
/**
* @noinspection PhpUnused
*/
class ProtectedResourceMetadata implements EntryPoint
{
public function __construct(
private ProtectedResourceMetadataProvider $provider,
) {}
public function run(Request $request, Response $response): void
{
$data = $this->provider->get();
$response->writeBody(Json::encode($data, JSON_PRETTY_PRINT));
}
}

View File

@@ -0,0 +1,49 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\EntryPoints;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\EntryPoint\EntryPoint;
use Espo\Tools\OAuthServer\AuthorizationService;
class Token implements EntryPoint
{
public function __construct(
private AuthorizationService $service,
) {}
public function run(Request $request, Response $response): void
{
$psr7Response = $this->service->token($request->toPsr7(), $response->toPsr7());
$response->applyPsr7($psr7Response);
}
}

View File

@@ -0,0 +1,173 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\EntryPoints;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\EntryPoint\EntryPoint;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Unauthorized;
use Espo\Tools\OAuthServer\Revoke\Data;
use Espo\Tools\OAuthServer\Revoke\TokenRevokeService;
use Espo\Tools\OAuthServer\Utils\ErrorResponseComposer;
/**
* @noinspection PhpUnused
*/
class TokenRevoke implements EntryPoint
{
private const string HEADER_AUTHORIZATION = 'Authorization';
public function __construct(
private TokenRevokeService $service,
) {}
public function run(Request $request, Response $response): void
{
$token = $request->getParsedBody()->token ?? throw new BadRequest("No 'token'.");
$tokenTypeHint = $request->getParsedBody()->token_type_hint ?? null;
[$clientId, $clientSecret] = $this->obtainAuthorizationFromHeader($request);
$clientId ??= $request->getParsedBody()->client_id ?? throw new BadRequest("No client ID.");
$clientSecret ??= $request->getParsedBody()->client_secret ?? null;
if (!is_string($clientId)) {
throw new BadRequest("Bad 'client_id' value");
}
if ($clientSecret !== null && !is_string($clientSecret)) {
throw new BadRequest("Bad 'client_secret' value");
}
if (!is_string($token)) {
throw new BadRequest("Bad 'token' value.");
}
if ($tokenTypeHint !== null && !is_string($tokenTypeHint)) {
throw new BadRequest("Bad 'token_type_hint' value.");
}
if (!$this->isTokenTypeValid($tokenTypeHint)) {
$response->applyPsr7(
ErrorResponseComposer::compose(
error: 'unsupported_token_type',
errorDescription: 'Token type is not supported.',
statusCode: 400,
)->toPsr7()
);
return;
}
$data = new Data(
clientId: $clientId,
clientSecret: $clientSecret,
token: $token,
tokenTypeHint: $tokenTypeHint,
);
try {
$this->service->revoke($data);
} catch (Unauthorized) {
$response->applyPsr7(
ErrorResponseComposer::compose(
error: 'invalid_client',
errorDescription: 'Invalid client credentials.',
statusCode: 401,
)->toPsr7()
);
}
}
private function isTokenTypeValid(?string $tokenTypeHint): bool
{
return
$tokenTypeHint === null ||
in_array($tokenTypeHint, [
TokenRevokeService::TYPE_ACCESS_TOKEN,
TokenRevokeService::TYPE_REFRESH_TOKEN,
]);
}
/**
* @return array{?string, ?string}
* @throws BadRequest
*/
private function obtainAuthorizationFromHeader(Request $request): array
{
$headerValue = $request->getHeader(self::HEADER_AUTHORIZATION);
if (!$headerValue) {
return [null, null];
}
$prefix = 'Basic ';
if (!str_starts_with($headerValue, $prefix)) {
return [null, null];
}
$string = substr($headerValue, strlen($prefix));
[$clientId, $clientSecret] = self::decodeAuthorizationString($string);
if (!$clientId) {
$clientId = null;
}
if (!$clientSecret) {
$clientSecret = null;
}
return [$clientId, $clientSecret];
}
/**
* @return array{string, string}
* @throws BadRequest
*/
private function decodeAuthorizationString(string $string): array
{
/** @var string $stringDecoded */
$stringDecoded = base64_decode($string);
if (!str_contains($stringDecoded, ':')) {
throw new BadRequest("Bad authorization string.");
}
[$username, $password] = explode(':', $stringDecoded, 2);
$username = trim($username);
$password = trim($password);
return [$username, $password];
}
}

View File

@@ -0,0 +1,115 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer;
use Espo\Core\Utils\DateTime;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\UpdateBuilder;
use Espo\Tools\OAuthServer\Entities\AccessToken;
use Espo\Tools\OAuthServer\Entities\AuthorizationCode;
use Espo\Tools\OAuthServer\Entities\ClientSecret;
use Espo\Tools\OAuthServer\Entities\RefreshToken;
/**
* Sets status Expired. Note that even Active status with the expiration date in past
* renders the token/secret inactive. Expired status serves an information purpose.
*/
class ExpireService
{
public function __construct(
private DateTime $dateTime,
private EntityManager $entityManager,
) {}
public function expireClientSecrets(): void
{
$secrets = $this->entityManager
->getRDBRepositoryByClass(ClientSecret::class)
->where([
ClientSecret::FIELD_STATUS => ClientSecret::STATUS_ACTIVE,
ClientSecret::FIELD_EXPIRATION_DATE . '<=' => $this->dateTime->getToday()->toString(),
])
->find();
foreach ($secrets as $secret) {
$secret->setExpired();
$this->entityManager->saveEntity($secret);
}
}
public function expireAccessTokens(): void
{
$query = UpdateBuilder::create()
->in(AccessToken::ENTITY_TYPE)
->set([
AccessToken::FIELD_STATUS => AccessToken::STATUS_EXPIRED,
])
->where([
AccessToken::FIELD_STATUS => AccessToken::STATUS_ACTIVE,
AccessToken::FIELD_EXPIRES_AT . '<=' => $this->dateTime->getNow()->toString(),
])
->build();
$this->entityManager->getQueryExecutor()->execute($query);
}
public function expireRefreshTokens(): void
{
$query = UpdateBuilder::create()
->in(RefreshToken::ENTITY_TYPE)
->set([
RefreshToken::FIELD_STATUS => RefreshToken::STATUS_EXPIRED,
])
->where([
RefreshToken::FIELD_STATUS => RefreshToken::STATUS_ACTIVE,
RefreshToken::FIELD_EXPIRES_AT . '<=' => $this->dateTime->getNow()->toString(),
])
->build();
$this->entityManager->getQueryExecutor()->execute($query);
}
public function expireAuthorizationCodes(): void
{
$query = UpdateBuilder::create()
->in(AuthorizationCode::ENTITY_TYPE)
->set([
AuthorizationCode::FIELD_STATUS => AuthorizationCode::STATUS_EXPIRED,
])
->where([
AuthorizationCode::FIELD_STATUS => AuthorizationCode::STATUS_ACTIVE,
AuthorizationCode::FIELD_EXPIRES_AT . '<=' => $this->dateTime->getNow()->toString(),
])
->build();
$this->entityManager->getQueryExecutor()->execute($query);
}
}

View File

@@ -0,0 +1,51 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Jobs;
use Espo\Core\Job\JobDataLess;
use Espo\Tools\OAuthServer\ExpireService;
/**
* @noinspection PhpUnused
*/
class OAuthServerControl implements JobDataLess
{
public function __construct(
private ExpireService $clientSecretExpireService,
) {}
public function run(): void
{
$this->clientSecretExpireService->expireClientSecrets();
$this->clientSecretExpireService->expireAccessTokens();
$this->clientSecretExpireService->expireRefreshTokens();
$this->clientSecretExpireService->expireAuthorizationCodes();
}
}

View File

@@ -0,0 +1,91 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\League;
use Espo\Core\Session\Session;
use Espo\Tools\OAuthServer\League\Entities\AuthCodeEntity;
use Espo\Tools\OAuthServer\League\Entities\ClientEntity;
use Espo\Tools\OAuthServer\League\Entities\ScopeEntity;
use InvalidArgumentException;
use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface;
use RuntimeException;
class AuthorizationRequestStorage
{
public function __construct(
private Session $session,
) {}
public function store(string $clientId, AuthorizationRequestInterface $request): void
{
/** @noinspection PhpConditionAlreadyCheckedInspection */
if (!$request instanceof AuthorizationRequest) {
throw new InvalidArgumentException("Unsupported authorization request instance.");
}
$this->session->set(self::composeSessionKey($clientId), serialize($request));
}
public function get(string $clientId, bool $clear = false): ?AuthorizationRequest
{
$key = self::composeSessionKey($clientId);
$raw = $this->session->get($key);
if (!$raw) {
return null;
}
if ($clear) {
$this->session->clear($key);
}
$request = unserialize($raw, [
'allowed_classes' => [
AuthorizationRequest::class,
ClientEntity::class,
ScopeEntity::class,
AuthCodeEntity::class,
]
]);
if (!$request instanceof AuthorizationRequest) {
throw new RuntimeException("Unserialization error.");
}
return $request;
}
public static function composeSessionKey(string $clientId): string
{
return "oAuthServerAuthorizeRequest_" . $clientId;
}
}

View File

@@ -0,0 +1,132 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\League;
use Espo\Core\Exceptions\Error;
use Espo\Tools\OAuthServer\League\Repositories\AccessTokenRepository;
use Espo\Tools\OAuthServer\League\Repositories\AuthCodeRepository;
use Espo\Tools\OAuthServer\League\Repositories\ClientRepository;
use Espo\Tools\OAuthServer\League\Repositories\RefreshTokenRepository;
use Espo\Tools\OAuthServer\League\Repositories\ScopeRepository;
use Espo\Tools\OAuthServer\Utils\CryptKeyProvider;
use Exception;
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\CryptKey;
use League\OAuth2\Server\Grant\AuthCodeGrant;
use League\OAuth2\Server\Grant\RefreshTokenGrant;
class AuthorizationServerFactory
{
public function __construct(
private ClientRepository $clientRepository,
private AccessTokenRepository $accessTokenRepository,
private ScopeRepository $scopeRepository,
private AuthCodeRepository $authCodeRepository,
private RefreshTokenRepository $refreshTokenRepository,
private CryptKeyProvider $cryptKeyProvider,
private ConfigDataProvider $configDataProvider,
) {}
/**
* @throws Error
*/
public function create(): AuthorizationServer
{
$server = new AuthorizationServer(
clientRepository: $this->clientRepository,
accessTokenRepository: $this->accessTokenRepository,
scopeRepository: $this->scopeRepository,
privateKey: $this->createPrivateKeyStub(),
encryptionKey: $this->cryptKeyProvider->getCryptKey(),
);
$server->revokeRefreshTokens(true);
$this->enableAuthorizationCodeGrant($server);
$this->enableRefreshTokenGrant($server);
return $server;
}
/**
* @throws Error
*/
private function enableAuthorizationCodeGrant(AuthorizationServer $server): void
{
try {
$grant = new AuthCodeGrant(
authCodeRepository: $this->authCodeRepository,
refreshTokenRepository: $this->refreshTokenRepository,
authCodeTTL: $this->configDataProvider->getAuthorizationCodeTtl(),
);
} catch (Exception $e) {
throw new Error("Error occurred.", previous: $e);
}
$server->enableGrantType(
grantType: $grant,
accessTokenTTL: $this->configDataProvider->getAccessTokenTtl(),
);
}
/**
* @throws Error
*/
private function enableRefreshTokenGrant(AuthorizationServer $server): void
{
try {
$grant = new RefreshTokenGrant($this->refreshTokenRepository);
} catch (Exception $e) {
throw new Error("Error occurred.", previous: $e);
}
$grant->setRefreshTokenTTL($this->configDataProvider->getRefreshTokenTtl());
$server->enableGrantType(
grantType: $grant,
accessTokenTTL: $this->configDataProvider->getAccessTokenTtl(),
);
}
/**
* Access token encryption is not used. Opaque tokens are used.
*/
private function createPrivateKeyStub(): CryptKey
{
return new class() extends CryptKey {
/**
* @noinspection PhpMissingParentConstructorInspection
*/
public function __construct() {}
};
}
}

View File

@@ -0,0 +1,78 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\League;
use Espo\Core\Utils\Config;
use DateInterval;
use DateMalformedIntervalStringException;
use RuntimeException;
class ConfigDataProvider
{
private const string AUTHORIZATION_CODE_TTL = 'PT10M';
private const string ACCESS_TOKEN_TTL = 'PT1H';
private const string REFRESH_TOKEN_TTL = 'P1M';
public function __construct(
private Config $config,
) {}
public function getAuthorizationCodeTtl(): DateInterval
{
$interval = $this->config->get('oAuthServer.ttl.authorizationCode') ?? self::AUTHORIZATION_CODE_TTL;
return self::convertIntervalToDateInterval($interval);
}
public function getAccessTokenTtl(): DateInterval
{
$interval = $this->config->get('oAuthServer.ttl.accessToken') ?? self::ACCESS_TOKEN_TTL;
return self::convertIntervalToDateInterval($interval);
}
public function getRefreshTokenTtl(): DateInterval
{
$interval = $this->config->get('oAuthServer.ttl.refreshToken') ?? self::REFRESH_TOKEN_TTL;
return self::convertIntervalToDateInterval($interval);
}
private static function convertIntervalToDateInterval(mixed $interval): DateInterval
{
try {
$value = new DateInterval($interval);
} catch (DateMalformedIntervalStringException $e) {
throw new RuntimeException("Bad interval '$interval'.", previous: $e);
}
return $value;
}
}

View File

@@ -0,0 +1,146 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\League\Entities;
use DateTimeImmutable;
use Espo\Core\Field\DateTime;
use Espo\Core\Field\Link;
use Espo\Tools\OAuthServer\Entities\AccessToken;
use Espo\Tools\OAuthServer\Utils\IdentifierHasher;
use InvalidArgumentException;
use League\OAuth2\Server\CryptKeyInterface;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
class AccessTokenEntity implements AccessTokenEntityInterface
{
public function __construct(
private AccessToken $entity,
private IdentifierHasher $hasher,
) {}
public function toString(): string
{
return $this->entity->getIdentifier();
}
/**
* @inheritDoc
* @return void
*/
public function setPrivateKey(CryptKeyInterface $privateKey): void
{}
public function __toString()
{
return $this->toString();
}
public function getIdentifier(): string
{
return $this->entity->getIdentifier();
}
public function setIdentifier(string $identifier): void
{
$this->entity->setIdentifier($identifier);
$hash = $this->hasher->hash($identifier);
$this->entity->setHash($hash);
}
public function getExpiryDateTime(): DateTimeImmutable
{
return $this->entity->getExpiresAt()->toDateTime();
}
public function setExpiryDateTime(DateTimeImmutable $dateTime): void
{
$this->entity->setExpiresAt(DateTime::fromDateTime($dateTime));
}
public function setUserIdentifier(string $identifier): void
{
$link = Link::create($identifier);
$this->entity->setUser($link);
}
public function getUserIdentifier(): ?string
{
/** @var non-empty-string */
return $this->entity->getUser()->getId();
}
public function getClient(): ClientEntityInterface
{
return ClientEntity::fromEntity($this->entity->getClient());
}
public function setClient(ClientEntityInterface $client): void
{
/** @noinspection PhpConditionAlreadyCheckedInspection */
if (!$client instanceof ClientEntity) {
throw new InvalidArgumentException("Not supported client entity.");
}
$this->entity->setClient(Link::create($client->entityId));
}
public function addScope(ScopeEntityInterface $scope): void
{
$scopes = $this->entity->getScopes();
if (!in_array($scope->getIdentifier(), $scopes)) {
$scopes[] = $scope->getIdentifier();
}
$this->entity->setScopes($scopes);
}
/**
* @return ScopeEntityInterface[]
*/
public function getScopes(): array
{
$scopes = $this->entity->getScopes();
return array_map(fn ($scope) => new ScopeEntity($scope), $scopes);
}
public function getEntity(): AccessToken
{
return $this->entity;
}
}

View File

@@ -0,0 +1,42 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\League\Entities;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Entities\Traits\AuthCodeTrait;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
class AuthCodeEntity implements AuthCodeEntityInterface
{
use EntityTrait;
use AuthCodeTrait;
use TokenEntityTrait;
}

View File

@@ -0,0 +1,87 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\League\Entities;
use Espo\Tools\OAuthServer\ClientType;
use Espo\Tools\OAuthServer\Entities\Client;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use UnexpectedValueException;
class ClientEntity implements ClientEntityInterface
{
/**
* @param non-empty-string $identifier
* @param string[] $redirectUri
*/
public function __construct(
public string $entityId,
private string $identifier,
private string $name,
private array $redirectUri,
private bool $isConfidential,
) {}
public static function fromEntity(Client $entity): self
{
return new self(
entityId: $entity->getId(),
identifier: $entity->getIdentifier() ?? throw new UnexpectedValueException("No client identifier."),
name: $entity->getName() ?? $entity->getId(),
redirectUri: $entity->getRedirectUris(),
isConfidential: $entity->getClientType() === ClientType::Confidential,
);
}
/**
* @return non-empty-string
*/
public function getIdentifier(): string
{
return $this->identifier;
}
public function getName(): string
{
return $this->name;
}
/**
* @return string[]
*/
public function getRedirectUri(): array
{
return $this->redirectUri;
}
public function isConfidential(): bool
{
return $this->isConfidential;
}
}

View File

@@ -0,0 +1,40 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\League\Entities;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait;
class RefreshTokenEntity implements RefreshTokenEntityInterface
{
use RefreshTokenTrait;
use EntityTrait;
}

View File

@@ -0,0 +1,53 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\League\Entities;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Entities\Traits\ScopeTrait;
class ScopeEntity implements ScopeEntityInterface
{
use ScopeTrait;
/**
* @param non-empty-string $identifier
*/
public function __construct(
private string $identifier,
) {}
/**
* @return non-empty-string
*/
public function getIdentifier(): string
{
return $this->identifier;
}
}

View File

@@ -0,0 +1,55 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\League\Entities;
use Espo\Entities\User;
use League\OAuth2\Server\Entities\UserEntityInterface;
use LogicException;
class UserEntity implements UserEntityInterface
{
public function __construct(
private User $user,
) {
if ($user->isSystem()) {
// Extra safety measure.
throw new LogicException();
}
}
/**
* @return non-empty-string
*/
public function getIdentifier(): string
{
/** @var non-empty-string */
return $this->user->getId();
}
}

View File

@@ -0,0 +1,124 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\League\Repositories;
use Espo\Core\Field\Link;
use Espo\ORM\EntityManager;
use Espo\Tools\OAuthServer\Entities\AccessToken;
use Espo\Tools\OAuthServer\League\Entities\AccessTokenEntity;
use Espo\Tools\OAuthServer\League\Entities\ClientEntity;
use Espo\Tools\OAuthServer\Repository\AccessTokenRepository as Repository;
use Espo\Tools\OAuthServer\Utils\IdentifierHasher;
use InvalidArgumentException;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
use SensitiveParameter;
class AccessTokenRepository implements AccessTokenRepositoryInterface
{
public function __construct(
private EntityManager $entityManager,
private Repository $repository,
private IdentifierHasher $hasher,
) {}
/**
* @param ScopeEntityInterface[] $scopes
*/
public function getNewToken(
ClientEntityInterface $clientEntity,
array $scopes,
?string $userIdentifier = null,
): AccessTokenEntityInterface {
$entity = $this->entityManager->getRDBRepositoryByClass(AccessToken::class)->getNew();
/** @noinspection PhpConditionAlreadyCheckedInspection */
if (!$clientEntity instanceof ClientEntity) {
throw new InvalidArgumentException("Bad client.");
}
if (!$userIdentifier) {
throw new InvalidArgumentException('No user ID.');
}
$entity->setClient(Link::create($clientEntity->entityId));
$accessToken = new AccessTokenEntity($entity, $this->hasher);
foreach ($scopes as $scope) {
$accessToken->addScope($scope);
}
$accessToken->setUserIdentifier($userIdentifier);
return $accessToken;
}
public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void
{
/** @noinspection PhpConditionAlreadyCheckedInspection */
if (!$accessTokenEntity instanceof AccessTokenEntity) {
throw new InvalidArgumentException("Not supported access token entity.");
}
$this->entityManager->saveEntity($accessTokenEntity->getEntity());
}
public function revokeAccessToken(#[SensitiveParameter] string $tokenId): void
{
$entity = $this->repository->getActiveByIdentifier($tokenId);
if (!$entity || $entity->isRevoked()) {
return;
}
$entity->setRevoked();
$this->entityManager->saveEntity($entity);
}
/**
* Not existent or inactive token is treated as revoked intentionally.
*/
public function isAccessTokenRevoked(#[SensitiveParameter] string $tokenId): bool
{
$entity = $this->repository->getActiveByIdentifier($tokenId);
if (!$entity) {
return true;
}
// Intentionally.
return !$entity->isActive();
}
}

View File

@@ -0,0 +1,119 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\League\Repositories;
use Espo\Core\Field\DateTime;
use Espo\Core\Field\Link;
use Espo\ORM\EntityManager;
use Espo\Tools\OAuthServer\Entities\AuthorizationCode;
use Espo\Tools\OAuthServer\League\Entities\AuthCodeEntity;
use Espo\Tools\OAuthServer\Repository\AuthorizationCodeRepository;
use Espo\Tools\OAuthServer\Repository\ClientRepository as ClientRepository;
use Espo\Tools\OAuthServer\Utils\IdentifierHasher;
use InvalidArgumentException;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
use RuntimeException;
use SensitiveParameter;
class AuthCodeRepository implements AuthCodeRepositoryInterface
{
public function __construct(
private EntityManager $entityManager,
private AuthorizationCodeRepository $repository,
private ClientRepository $clientRepository,
private IdentifierHasher $hasher,
) {}
public function getNewAuthCode(): AuthCodeEntity
{
return new AuthCodeEntity();
}
public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): void
{
$entity = $this->entityManager->getRDBRepositoryByClass(AuthorizationCode::class)->getNew();
$userId = $authCodeEntity->getUserIdentifier();
if (!is_string($userId)) {
throw new InvalidArgumentException("User ID must be string.");
}
$scopes = array_map(fn ($scope) => $scope->getIdentifier(), $authCodeEntity->getScopes());
$redirectUri = $authCodeEntity->getRedirectUri() ?? throw new InvalidArgumentException("No redirect URI");
$clientId = $authCodeEntity->getClient()->getIdentifier();
$client = $this->clientRepository->getActiveByIdentifier($clientId);
if (!$client) {
throw new RuntimeException("Client not found.");
}
$hash = $this->hasher->hash($authCodeEntity->getIdentifier());
$entity
->setClient($client)
->setHash($hash)
->setUser(Link::create($userId))
->setScopes($scopes)
->setRedirectUri($redirectUri)
->setExpiresAt(DateTime::fromDateTime($authCodeEntity->getExpiryDateTime()));
$this->entityManager->saveEntity($entity);
}
public function revokeAuthCode(#[SensitiveParameter] string $codeId): void
{
$code = $this->repository->getActiveByIdentifier($codeId);
if (!$code || $code->isRevoked()) {
return;
}
$code->setRevoked();
$this->entityManager->saveEntity($code);
}
public function isAuthCodeRevoked(#[SensitiveParameter] string $codeId): bool
{
$entity = $this->repository->getActiveByIdentifier($codeId);
if (!$entity) {
return true;
}
// Intentionally.
return !$entity->isActive();
}
}

View File

@@ -0,0 +1,70 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\League\Repositories;
use Espo\Tools\OAuthServer\ClientValidator;
use Espo\Tools\OAuthServer\League\Entities\ClientEntity;
use Espo\Tools\OAuthServer\Repository\ClientRepository as Repository;
use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
use SensitiveParameter;
class ClientRepository implements ClientRepositoryInterface
{
public function __construct(
private Repository $repository,
private ClientValidator $clientValidator,
) {}
public function getClientEntity(string $clientIdentifier): ?ClientEntity
{
$client = $this->repository->getActiveByIdentifier($clientIdentifier);
if (!$client) {
return null;
}
return ClientEntity::fromEntity($client);
}
public function validateClient(
string $clientIdentifier,
#[SensitiveParameter] ?string $clientSecret,
?string $grantType,
): bool {
$client = $this->repository->getActiveByIdentifier($clientIdentifier);
if (!$client) {
return false;
}
return $this->clientValidator->validate($client, $clientSecret);
}
}

View File

@@ -0,0 +1,122 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\League\Repositories;
use Espo\Core\Field\DateTime;
use Espo\Core\Field\Link;
use Espo\ORM\EntityManager;
use Espo\Tools\OAuthServer\Entities\RefreshToken;
use Espo\Tools\OAuthServer\League\Entities\AccessTokenEntity;
use Espo\Tools\OAuthServer\League\Entities\RefreshTokenEntity;
use Espo\Tools\OAuthServer\Repository\ClientRepository as ClientRepositoryInternal;
use Espo\Tools\OAuthServer\Repository\RefreshTokenRepository as RefreshTokenRepositoryInternal;
use Espo\Tools\OAuthServer\Utils\IdentifierHasher;
use InvalidArgumentException;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
use RuntimeException;
use SensitiveParameter;
class RefreshTokenRepository implements RefreshTokenRepositoryInterface
{
public function __construct(
private EntityManager $entityManager,
private RefreshTokenRepositoryInternal $repository,
private ClientRepositoryInternal $clientRepository,
private IdentifierHasher $hasher,
) {}
public function getNewRefreshToken(): RefreshTokenEntity
{
return new RefreshTokenEntity();
}
public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void
{
$entity = $this->entityManager->getRDBRepositoryByClass(RefreshToken::class)->getNew();
$userId = $refreshTokenEntity->getAccessToken()->getUserIdentifier();
if (!is_string($userId)) {
throw new InvalidArgumentException("User ID must be string.");
}
$clientId = $refreshTokenEntity->getAccessToken()->getClient()->getIdentifier();
$client = $this->clientRepository->getActiveByIdentifier($clientId) ??
throw new RuntimeException("Client not found.");
$accessToken = $refreshTokenEntity->getAccessToken();
/** @noinspection PhpConditionAlreadyCheckedInspection */
if (!$accessToken instanceof AccessTokenEntity) {
throw new InvalidArgumentException("Not supported access token implementation.");
}
$hash = $this->hasher->hash($refreshTokenEntity->getIdentifier());
$entity
->setClient($client)
->setAccessToken($accessToken->getEntity())
->setHash($hash)
->setUser(Link::create($userId))
->setExpiresAt(DateTime::fromDateTime($refreshTokenEntity->getExpiryDateTime()));
$this->entityManager->saveEntity($entity);
}
/**
* @todo Delay?
*/
public function revokeRefreshToken(#[SensitiveParameter] string $tokenId): void
{
$token = $this->repository->getActiveByIdentifier($tokenId);
if (!$token || $token->isRevoked()) {
return;
}
$token->setRevoked();
$this->entityManager->saveEntity($token);
}
public function isRefreshTokenRevoked(#[SensitiveParameter] string $tokenId): bool
{
$entity = $this->repository->getActiveByIdentifier($tokenId);
if (!$entity) {
return true;
}
// Intentionally.
return !$entity->isActive();
}
}

View File

@@ -0,0 +1,82 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\League\Repositories;
use Espo\Tools\OAuthServer\League\Entities\ScopeEntity;
use Espo\Tools\OAuthServer\Scope\UserAvailableScopesFilter;
use Espo\Tools\OAuthServer\Scope\ScopeValidator;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
class ScopeRepository implements ScopeRepositoryInterface
{
public function __construct(
private ScopeValidator $scopeValidator,
private UserAvailableScopesFilter $userAvailableScopesProvider,
) {}
/**
* @param non-empty-string $identifier
*/
public function getScopeEntityByIdentifier(string $identifier): ?ScopeEntity
{
if (!$this->scopeValidator->validate($identifier)) {
return null;
}
return new ScopeEntity($identifier);
}
/**
* @inheritDoc
*/
public function finalizeScopes(
array $scopes,
?string $grantType,
ClientEntityInterface $clientEntity,
?string $userIdentifier = null,
?string $authCodeId = null,
): array {
if (!$userIdentifier) {
return $scopes;
}
$ids = array_map(fn ($it) => $it->getIdentifier(), $scopes);
$availableIds = $this->userAvailableScopesProvider->filter($userIdentifier, $ids);
$scopes = array_filter($scopes, function ($scope) use ($availableIds) {
return in_array($scope->getIdentifier(), $availableIds);
});
return array_values($scopes);
}
}

View File

@@ -0,0 +1,116 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Login;
use Espo\Core\Api\Request;
use Espo\Core\Authentication\Login;
use Espo\Core\Authentication\Login\Data;
use Espo\Core\Authentication\Repository\UserRepository;
use Espo\Core\Authentication\Result;
use Espo\Core\Field\DateTime;
use Espo\Core\Utils\DateTime\Clock;
use Espo\Tools\OAuthServer\Repository\AccessTokenRepository;
use Espo\Tools\OAuthServer\Utils\ErrorResponseComposer;
/**
* @noinspection PhpUnused
*/
class OAuthLogin implements Login
{
private const string HEADER_AUTHORIZATION = 'Authorization';
private const string SCHEME = 'Bearer';
public function __construct(
private AccessTokenRepository $repository,
private Clock $clock,
private UserRepository $userRepository,
) {}
public function login(Data $data, Request $request): Result
{
$header = $request->getHeader(self::HEADER_AUTHORIZATION);
$prefix = self::SCHEME . ' ';
if (!$header || !str_starts_with($header, $prefix)) {
return Result::fail(Result\FailReason::ERROR);
}
$opaqueToken = substr($header, strlen($prefix));
$accessToken = $this->repository->getActiveByIdentifier($opaqueToken);
if (!$accessToken) {
$response = ErrorResponseComposer::compose(
error: 'access_denied',
errorDescription: 'Invalid access token.',
);
return Result::fail(Result\FailReason::WRONG_CREDENTIALS, $response);
}
$now = DateTime::fromDateTime($this->clock->now());
if ($accessToken->getExpiresAt()->isLessThanOrEqualTo($now)) {
$response = ErrorResponseComposer::compose(
error: 'access_denied',
errorDescription: 'The access token expired.',
);
return Result::fail(Result\FailReason::DENIED, $response);
}
if (!$accessToken->getClient()->isActive()) {
$response = ErrorResponseComposer::compose(
error: 'access_denied',
errorDescription: 'The client is not active.',
);
return Result::fail(Result\FailReason::DENIED, $response);
}
$userLink = $accessToken->getUser();
$user = $this->userRepository->findOneById($userLink->getId());
if (!$user) {
$response = ErrorResponseComposer::compose(
error: 'access_denied',
errorDescription: 'User not found.',
);
return Result::fail(Result\FailReason::DENIED, $response);
}
$user->setScopes($accessToken->getScopes());
return Result::success($user);
}
}

View File

@@ -0,0 +1,78 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Metadata;
use Espo\Core\Utils\Cache\DataCacheAccess;
use Espo\Core\Utils\Config\ApplicationConfig;
use stdClass;
class AuthorizationServerMetadataProvider
{
private const string CACHE_KEY = 'oAuthServer/authorizationServerMetadata';
/**
* @param DataCacheAccess<stdClass> $dataCacheAccess
*/
public function __construct(
private DataCacheAccess $dataCacheAccess,
private ApplicationConfig $applicationConfig,
) {
$this->dataCacheAccess->init(
key: self::CACHE_KEY,
loader: fn () => $this->buildData(),
);
}
public function get(): stdClass
{
return $this->dataCacheAccess->get();
}
private function buildData(): stdClass
{
return (object) [
'issuer' => self::serverIdentifierFromUrl($this->applicationConfig->getSiteUrl()),
'authorization_endpoint' => $this->applicationConfig->getSiteUrl() . '/oauth/authorize',
'token_endpoint' => $this->applicationConfig->getSiteUrl() . '/oauth/token',
'revocation_endpoint' => $this->applicationConfig->getSiteUrl() . '/oauth/token/revoke',
];
}
private static function serverIdentifierFromUrl(string $url): string
{
$parsed = parse_url($url);
$scheme = isset($parsed['scheme']) ? $parsed['scheme'] . '://' : '';
$host = $parsed['host'] ?? '';
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
return $scheme . $host . $port;
}
}

View File

@@ -0,0 +1,81 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Metadata;
use Espo\Core\Utils\Cache\DataCacheAccess;
use Espo\Core\Utils\Config\ApplicationConfig;
use stdClass;
class ProtectedResourceMetadataProvider
{
private const string CACHE_KEY = 'oAuthServer/protectedResourceMetadata';
/**
* @param DataCacheAccess<stdClass> $dataCacheAccess
*/
public function __construct(
private DataCacheAccess $dataCacheAccess,
private ApplicationConfig $applicationConfig,
) {
$this->dataCacheAccess->init(
key: self::CACHE_KEY,
loader: fn () => $this->buildData(),
);
}
public function get(): stdClass
{
return $this->dataCacheAccess->get();
}
private function buildData(): stdClass
{
return (object) [
'resource' => $this->applicationConfig->getSiteUrl(),
'authorization_servers' => [
self::serverIdentifierFromUrl($this->applicationConfig->getSiteUrl()),
],
'bearer_methods_supported' => [
'header',
],
];
}
private static function serverIdentifierFromUrl(string $url): string
{
$parsed = parse_url($url);
$scheme = isset($parsed['scheme']) ? $parsed['scheme'] . '://' : '';
$host = $parsed['host'] ?? '';
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
return $scheme . $host . $port;
}
}

View File

@@ -0,0 +1,136 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Record\MassActions;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\MassAction\Data;
use Espo\Core\MassAction\MassAction;
use Espo\Core\MassAction\Params;
use Espo\Core\MassAction\QueryBuilder;
use Espo\Core\MassAction\Result;
use Espo\Entities\User;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Tools\OAuthServer\Entities\AccessToken;
use Espo\Tools\OAuthServer\Entities\AuthorizationCode;
use Espo\Tools\OAuthServer\Entities\RefreshToken;
use Espo\Tools\OAuthServer\Record\TokenService;
use RuntimeException;
/**
* @noinspection PhpUnused
*/
class RevokeMassAction implements MassAction
{
/**
* @var string[]
*/
private array $entityTypes = [
AccessToken::ENTITY_TYPE,
RefreshToken::ENTITY_TYPE,
AuthorizationCode::ENTITY_TYPE,
];
public function __construct(
private TokenService $service,
private User $user,
private QueryBuilder $queryBuilder,
private Acl $acl,
private EntityManager $entityManager,
) {}
public function process(Params $params, Data $data): Result
{
$entityType = $params->getEntityType();
if (!in_array($entityType, $this->entityTypes)) {
throw new BadRequest();
}
$this->assertAccess($entityType);
$query = $this->queryBuilder->build($params);
$collection = $this->entityManager->getRDBRepository($entityType)
->clone($query)
->sth()
->find();
$ids = [];
$count = 0;
foreach ($collection as $entity) {
try {
$this->assertEntity($entity);
/** @noinspection PhpParamsInspection */
$this->service->revoke($entity);
$ids[] = $entity->getId();
$count ++;
} catch (Conflict|Forbidden) {
continue;
}
}
return new Result($count, $ids);
}
/**
* @throws Forbidden
*/
private function assertAccess(string $entityType): void
{
if (!$this->user->isEffectiveAdmin()) {
throw new Forbidden();
}
if (!$this->acl->check($entityType, Acl\Table::ACTION_EDIT)) {
throw new Forbidden("No edit access.");
}
}
/**
* @phpstan-assert AccessToken|RefreshToken|AuthorizationCode $entity
*/
private function assertEntity(Entity $entity): void
{
if (
!$entity instanceof AccessToken &&
!$entity instanceof RefreshToken &&
!$entity instanceof AuthorizationCode
) {
throw new RuntimeException();
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Record;
use Espo\Core\Acl;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Forbidden;
use Espo\ORM\EntityManager;
use Espo\Tools\OAuthServer\Entities\AccessToken;
use Espo\Tools\OAuthServer\Entities\AuthorizationCode;
use Espo\Tools\OAuthServer\Entities\RefreshToken;
class TokenService
{
public function __construct(
private Acl $acl,
private EntityManager $entityManager,
) {}
/**
* @throws Forbidden
* @throws Conflict
*/
public function revoke(AccessToken|RefreshToken|AuthorizationCode $token): void
{
if (!$this->acl->checkEntityEdit($token)) {
throw new Forbidden("No edit access.");
}
if (!$token->isActive()) {
throw new Conflict("Token is not active.");
}
$token->setRevoked();
$this->entityManager->saveEntity($token);
}
}

View File

@@ -0,0 +1,84 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Repository;
use Espo\ORM\SthCollection;
use Espo\Tools\OAuthServer\Utils\IdentifierHasher;
use Espo\ORM\EntityManager;
use Espo\Tools\OAuthServer\Entities\AccessToken;
use SensitiveParameter;
class AccessTokenRepository
{
public function __construct(
private EntityManager $entityManager,
private IdentifierHasher $hasher,
) {}
public function getActiveByIdentifier(#[SensitiveParameter] string $identifier): ?AccessToken
{
$hash = $this->hasher->hash($identifier);
return $this->entityManager
->getRDBRepositoryByClass(AccessToken::class)
->where([
AccessToken::FIELD_HASH => $hash,
AccessToken::FIELD_STATUS => AccessToken::STATUS_ACTIVE,
])
->findOne();
}
public function getByIdentifier(#[SensitiveParameter] string $identifier): ?AccessToken
{
$hash = $this->hasher->hash($identifier);
return $this->entityManager
->getRDBRepositoryByClass(AccessToken::class)
->where([
AccessToken::FIELD_HASH => $hash,
])
->findOne();
}
/**
* @return SthCollection<AccessToken>
*/
public function findActiveForClientIdAndUser(string $clientId, string $userId): SthCollection
{
return $this->entityManager->getRDBRepositoryByClass(AccessToken::class)
->where([
AccessToken::FIELD_STATUS => AccessToken::STATUS_ACTIVE,
AccessToken::FIELD_CLIENT . 'Id' => $clientId,
AccessToken::FIELD_USER . 'Id' => $userId,
])
->sth()
->find();
}
}

View File

@@ -0,0 +1,56 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Repository;
use Espo\Tools\OAuthServer\Utils\IdentifierHasher;
use Espo\ORM\EntityManager;
use Espo\Tools\OAuthServer\Entities\AuthorizationCode;
use SensitiveParameter;
class AuthorizationCodeRepository
{
public function __construct(
private EntityManager $entityManager,
private IdentifierHasher $hasher,
) {}
public function getActiveByIdentifier(#[SensitiveParameter] string $identifier): ?AuthorizationCode
{
$hash = $this->hasher->hash($identifier);
return $this->entityManager
->getRDBRepositoryByClass(AuthorizationCode::class)
->where([
AuthorizationCode::FIELD_HASH => $hash,
AuthorizationCode::FIELD_STATUS => AuthorizationCode::STATUS_ACTIVE,
])
->findOne();
}
}

View File

@@ -0,0 +1,109 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Repository;
use Espo\Core\Name\Field;
use Espo\ORM\EntityCollection;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Condition as Cond;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Query\SelectBuilder;
use Espo\Tools\OAuthServer\Entities\AccessToken;
use Espo\Tools\OAuthServer\Entities\Client;
use Espo\Tools\OAuthServer\Entities\RefreshToken;
class ClientRepository
{
public function __construct(
private EntityManager $entityManager,
) {}
public function getActiveByIdentifier(string $identifier): ?Client
{
return $this->entityManager
->getRDBRepositoryByClass(Client::class)
->where([
Client::FIELD_IDENTIFIER => $identifier,
Client::FIELD_STATUS => Client::STATUS_ACTIVE,
])
->findOne();
}
public function getByIdentifier(string $identifier): ?Client
{
return $this->entityManager
->getRDBRepositoryByClass(Client::class)
->where([
Client::FIELD_IDENTIFIER => $identifier,
])
->findOne();
}
/**
* @return EntityCollection<Client>
*/
public function findConnectedForUser(string $userId, int $limit): EntityCollection
{
return $this->entityManager->getRDBRepositoryByClass(Client::class)
->where(
Cond::or(
Cond::in(
Expr::column(Attribute::ID),
SelectBuilder::create()
->from(RefreshToken::ENTITY_TYPE)
->select([RefreshToken::FIELD_CLIENT . 'Id'])
->where([
RefreshToken::FIELD_STATUS => RefreshToken::STATUS_ACTIVE,
RefreshToken::FIELD_USER . 'Id' => $userId,
])
->build()
),
Cond::in(
Expr::column(Attribute::ID),
SelectBuilder::create()
->from(AccessToken::ENTITY_TYPE)
->select([RefreshToken::FIELD_CLIENT . 'Id'])
->where([
AccessToken::FIELD_STATUS => RefreshToken::STATUS_ACTIVE,
AccessToken::FIELD_USER . 'Id' => $userId,
])
->build()
),
)
)
->where([
Client::FIELD_IDENTIFIER . '!=' => null,
])
->order(Field::NAME)
->limit(0, $limit)
->find();
}
}

View File

@@ -0,0 +1,72 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Repository;
use Espo\ORM\SthCollection;
use Espo\Tools\OAuthServer\Utils\IdentifierHasher;
use Espo\ORM\EntityManager;
use Espo\Tools\OAuthServer\Entities\RefreshToken;
use SensitiveParameter;
class RefreshTokenRepository
{
public function __construct(
private EntityManager $entityManager,
private IdentifierHasher $hasher,
) {}
public function getActiveByIdentifier(#[SensitiveParameter] string $identifier): ?RefreshToken
{
$hash = $this->hasher->hash($identifier);
return $this->entityManager
->getRDBRepositoryByClass(RefreshToken::class)
->where([
RefreshToken::FIELD_HASH => $hash,
RefreshToken::FIELD_STATUS => RefreshToken::STATUS_ACTIVE,
])
->findOne();
}
/**
* @return SthCollection<RefreshToken>
*/
public function findActiveForClientIdAndUser(string $clientId, string $userId): SthCollection
{
return $this->entityManager->getRDBRepositoryByClass(RefreshToken::class)
->where([
RefreshToken::FIELD_STATUS => RefreshToken::STATUS_ACTIVE,
RefreshToken::FIELD_CLIENT . 'Id' => $clientId,
RefreshToken::FIELD_USER . 'Id' => $userId,
])
->sth()
->find();
}
}

View File

@@ -0,0 +1,43 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Revoke;
use SensitiveParameter;
readonly class Data
{
public function __construct(
public string $clientId,
#[SensitiveParameter]
public ?string $clientSecret,
public string $token,
public ?string $tokenTypeHint,
) {}
}

View File

@@ -0,0 +1,125 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Revoke;
use Espo\Core\Exceptions\Unauthorized;
use Espo\ORM\EntityManager;
use Espo\Tools\OAuthServer\ClientValidator;
use Espo\Tools\OAuthServer\Entities\Client;
use Espo\Tools\OAuthServer\Repository\AccessTokenRepository;
use Espo\Tools\OAuthServer\Repository\ClientRepository;
use Espo\Tools\OAuthServer\Repository\RefreshTokenRepository;
use Espo\Tools\OAuthServer\Utils\RefreshTokenDataExtractor;
use Exception;
use SensitiveParameter;
class TokenRevokeService
{
public const string TYPE_ACCESS_TOKEN = 'access_token';
public const string TYPE_REFRESH_TOKEN = 'refresh_token';
public function __construct(
private AccessTokenRepository $accessTokenRepository,
private RefreshTokenRepository $refreshTokenRepository,
private ClientRepository $clientRepository,
private ClientValidator $clientValidator,
private EntityManager $entityManager,
private RefreshTokenDataExtractor $refreshTokenDataExtractor,
) {}
/**
* @throws Unauthorized
*/
public function revoke(#[SensitiveParameter] Data $data): void
{
$tokenTypeHint = $data->tokenTypeHint;
$token = $data->token;
$client = $this->clientRepository->getActiveByIdentifier($data->clientId);
if (!$client || !$this->clientValidator->validate($client, $data->clientSecret)) {
throw new Unauthorized();
}
if (!$tokenTypeHint || $tokenTypeHint === self::TYPE_ACCESS_TOKEN) {
$this->revokeAccessToken($token, $client);
}
if (!$tokenTypeHint || $tokenTypeHint === self::TYPE_REFRESH_TOKEN) {
$this->revokeRefreshToken($token, $client);
}
}
private function revokeAccessToken(#[SensitiveParameter] string $token, Client $client): void
{
$accessToken = $this->accessTokenRepository->getActiveByIdentifier($token);
if (
!$accessToken ||
$accessToken->getClient()->getId() !== $client->getId()
) {
return;
}
$accessToken->setRevoked();
$this->entityManager->saveEntity($accessToken);
}
private function revokeRefreshToken(#[SensitiveParameter] string $token, Client $client): void
{
try {
$identifier = $this->refreshTokenDataExtractor->extractIdentifier($token);
} catch (Exception) {
return;
}
$refreshToken = $this->refreshTokenRepository->getActiveByIdentifier($identifier);
if (
!$refreshToken ||
$refreshToken->getClient()->getId() !== $client->getId()
) {
return;
}
$accessToken = $refreshToken->getAccessToken();
$refreshToken->setRevoked();
$this->entityManager->saveEntity($refreshToken);
if ($accessToken) {
$accessToken->setRevoked();
$this->entityManager->saveEntity($accessToken);
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Scope;
use Espo\Core\Acl\Scope;
use stdClass;
class ScopeSorter
{
/**
* @var array<string, int>
*/
private array $priority = [
Scope::GLOBAL => 0,
Scope::ADMIN => 1,
];
/**
* @param (stdClass & object{name: string, label: string})[] $items
* @return (stdClass & object{name: string, label: string})[]
*/
public function sortDataItems(array $items): array
{
usort($items, function ($a, $b) {
$aPriority = $this->priority[$a->name] ?? PHP_INT_MAX;
$bPriority = $this->priority[$b->name] ?? PHP_INT_MAX;
return $aPriority <=> $bPriority ?: strcasecmp($a->label, $b->label);
});
return $items;
}
/**
* @param non-empty-string[] $scopes
* @return non-empty-string[]
*/
public function sort(array $scopes): array
{
usort($scopes, function ($a, $b) {
$aPriority = $this->priority[$a] ?? PHP_INT_MAX;
$bPriority = $this->priority[$b] ?? PHP_INT_MAX;
return $aPriority <=> $bPriority ?: strcmp($a, $b);
});
return $scopes;
}
}

View File

@@ -0,0 +1,42 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Scope;
class ScopeValidator
{
public function __construct(
private ScopesProvider $scopesProvider,
) {}
public function validate(string $scope): bool
{
return in_array($scope, $this->scopesProvider->get());
}
}

View File

@@ -0,0 +1,94 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Scope;
use Espo\Core\Acl\Scope;
use Espo\Core\Utils\Metadata;
/**
* @since 10.1.0
*/
class ScopesProvider
{
/**
* @var string[]
*/
private array $commonScopes = [
Scope::ADMIN,
Scope::GLOBAL,
];
public function __construct(
private Metadata $metadata,
) {}
/**
* @return string[]
*/
public function get(): array
{
return [
...$this->commonScopes,
...$this->getAclScopes(),
];
}
/**
* @return string[]
*/
private function getAclScopes(): array
{
/**
* @var array<string, array{
* disabled?: bool,
* acl?: mixed,
* }> $scopesDefs
*/
$scopesDefs = $this->metadata->get("scopes", []);
$list = [];
foreach ($scopesDefs as $scope => $defs) {
if (
($defs['disabled'] ?? false) ||
!($defs['acl'] ?? false) ||
in_array($scope, $this->commonScopes)
) {
continue;
}
$list[] = $scope;
}
sort($list);
return $list;
}
}

View File

@@ -0,0 +1,68 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Scope;
use Espo\Core\Acl\Scope;
use Espo\Core\Utils\Acl\UserAclManagerProvider;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
class UserAvailableScopesFilter
{
public function __construct(
private EntityManager $entityManager,
private UserAclManagerProvider $aclManagerProvider,
) {}
/**
* @param string[] $scopes
* @return string[]
*/
public function filter(string $userId, array $scopes): array
{
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId);
if (!$user) {
return [];
}
$aclManager = $this->aclManagerProvider->get($user);
$scopes = array_filter($scopes, function ($scope) use ($aclManager, $user) {
if ($scope === Scope::GLOBAL) {
return true;
}
return $aclManager->tryCheck($user, $scope);
});
return array_values($scopes);
}
}

View File

@@ -0,0 +1,79 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Secret;
use Espo\Core\Name\Field;
use Espo\Core\Utils\DateTime;
use Espo\Core\Utils\PasswordHash;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\Part\Order;
use Espo\Tools\OAuthServer\Entities\Client;
use Espo\Tools\OAuthServer\Entities\ClientSecret;
use SensitiveParameter;
class SecretValidator
{
public function __construct(
private EntityManager $entityManager,
private DateTime $dateTime,
private PasswordHash $passwordHash,
) {}
public function validate(Client $client, #[SensitiveParameter] string $secret): bool
{
$secrets = $this->entityManager
->getRDBRepositoryByClass(ClientSecret::class)
->sth()
->where([
ClientSecret::ATTR_CLIENT_ID => $client->getId(),
ClientSecret::FIELD_STATUS => ClientSecret::STATUS_ACTIVE,
])
->order(Field::CREATED_AT, Order::DESC)
->find();
foreach ($secrets as $entry) {
/** @noinspection PhpParamsInspection */
if ($this->match($entry, $secret)) {
return true;
}
}
return false;
}
private function match(ClientSecret $entry, #[SensitiveParameter] string $secret): bool
{
if ($entry->getExpirationDate()?->isLessThanOrEqualTo($this->dateTime->getToday())) {
return false;
}
return $this->passwordHash->verify($secret, $entry->getHash());
}
}

View File

@@ -0,0 +1,54 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Utils;
use Defuse\Crypto\Crypto;
use Defuse\Crypto\Exception\EnvironmentIsBrokenException;
use Defuse\Crypto\Exception\WrongKeyOrModifiedCiphertextException;
use RuntimeException;
use SensitiveParameter;
class Crypt
{
public function __construct(
private CryptKeyProvider $keyProvider,
) {}
public function decrypt(#[SensitiveParameter] string $value): string
{
$key = $this->keyProvider->getCryptKey();
try {
return Crypto::decryptWithPassword($value, $key);
} catch (EnvironmentIsBrokenException|WrongKeyOrModifiedCiphertextException $e) {
throw new RuntimeException(previous: $e);
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Utils;
use Espo\Core\Utils\Config;
use Random\RandomException;
use RuntimeException;
class CryptKeyProvider
{
private const string PARAM_CRYPT_KEY = 'oAuthServerCryptKey';
public function __construct(
private Config $config,
private Config\ConfigWriter $configWriter,
) {}
public function getCryptKey(): string
{
if (!$this->config->has(self::PARAM_CRYPT_KEY)) {
$this->configWriter->set(self::PARAM_CRYPT_KEY, $this->generateKey());
$this->configWriter->save();
}
return $this->config->get(self::PARAM_CRYPT_KEY) ?? throw new RuntimeException("No crypt key.");
}
private function generateKey(): string
{
try {
$key = bin2hex(random_bytes(32));
} catch (RandomException $e) {
throw new RuntimeException(previous: $e);
}
return $key;
}
}

View File

@@ -0,0 +1,61 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Utils;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
class ErrorResponseComposer
{
/**
* @param ?int<400, 499> $statusCode
*/
public static function compose(
string $error,
string $errorDescription,
?int $statusCode = null,
): Response {
$header = "Bearer error=\"$error\", error_description=\"$errorDescription\"";
$response = ResponseComposer::json([
'error' => $error,
'error_description' => $errorDescription,
]);
if ($statusCode) {
$response->setStatus($statusCode);
}
$response->setHeader('WWW-Authenticate', $header);
return $response;
}
}

View File

@@ -0,0 +1,40 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Utils;
use SensitiveParameter;
class IdentifierHasher
{
public function hash(#[SensitiveParameter] string $value): string
{
return hash('sha256', $value);
}
}

View File

@@ -0,0 +1,66 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Utils;
use Espo\Core\Utils\Json;
use Exception;
use InvalidArgumentException;
use JsonException;
use SensitiveParameter;
class RefreshTokenDataExtractor
{
public function __construct(
private Crypt $crypt,
) {}
public function extractIdentifier(#[SensitiveParameter] string $token): string
{
try {
$decrypted = $this->crypt->decrypt($token);
} catch (Exception) {
throw new InvalidArgumentException("Could not decrypt.");
}
try {
$tokenData = Json::decode($decrypted, true);
} catch (JsonException) {
throw new InvalidArgumentException("Could not JSON decode.");
}
$identifier = $tokenData['refresh_token_id'] ?? null;
if (!is_string($identifier)) {
throw new InvalidArgumentException("No identifier.");
}
return $identifier;
}
}

View File

@@ -0,0 +1,43 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 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\Tools\OAuthServer\Utils;
/**
* @internal
*/
class UriUtil
{
public static function makeRedirectUri(string $uri, string $state): string
{
$uri .= !str_contains($uri, '?') ? '?' : '&';
return $uri . http_build_query(['state' => $state]);
}
}