From f6e529deb0cbd169a2da473fc1eb03f999851f29 Mon Sep 17 00:00:00 2001 From: Bojan Bogdanovic <info@bojanbogdanovic.nl> Date: Tue, 18 Feb 2025 15:53:37 +0100 Subject: [PATCH 01/10] Issue #3507450: Introduce access policy + cache context for oauth2 scopes --- simple_oauth.services.yml | 13 ++- .../SimpleOauthAuthenticationProvider.php | 99 +++++-------------- src/Authentication/TokenAuthUser.php | 39 ++++---- src/Entity/Oauth2Scope.php | 9 ++ src/Oauth2AccessPolicy.php | 47 +++++++++ src/Oauth2ScopeCacheContext.php | 57 +++++++++++ src/Oauth2ScopeInterface.php | 8 ++ src/Oauth2ScopeProvider.php | 19 ++++ src/Oauth2ScopeProviderInterface.php | 8 ++ src/Plugin/ScopeGranularity/Permission.php | 7 ++ src/Plugin/ScopeGranularity/Role.php | 28 ++++++ src/Plugin/ScopeGranularityInterface.php | 8 ++ .../ScopeGranularity/TestGranularity.php | 7 ++ .../SimpleOauthAuthenticationTest.php | 11 ++- 14 files changed, 267 insertions(+), 93 deletions(-) create mode 100644 src/Oauth2AccessPolicy.php create mode 100644 src/Oauth2ScopeCacheContext.php diff --git a/simple_oauth.services.yml b/simple_oauth.services.yml index af6635a..f568b8b 100644 --- a/simple_oauth.services.yml +++ b/simple_oauth.services.yml @@ -26,6 +26,8 @@ services: - '@psr7.http_foundation_factory' - '@path.validator' - '@router.route_provider' + - '@request_stack' + - '@permission_checker' tags: - { name: authentication_provider, provider_id: oauth2, global: TRUE, priority: 35 } simple_oauth.page_cache_request_policy.disallow_oauth2_token_requests: @@ -38,7 +40,16 @@ services: arguments: - '@Drupal\simple_oauth\EventSubscriber\ExceptionLoggingSubscriber.inner' - '@logger.channel.simple_oauth' - + cache_context.oauth2_scopes: + class: Drupal\simple_oauth\Oauth2ScopeCacheContext + arguments: [ '@current_user' ] + tags: + - { name: cache.context } + access_policy.simple_oauth: + class: Drupal\simple_oauth\Oauth2AccessPolicy + arguments: [ '@simple_oauth.oauth2_scope.provider' ] + tags: + - { name: access_policy } simple_oauth.normalizer.oauth2_token: class: Drupal\simple_oauth\Normalizer\TokenEntityNormalizer arguments: [ '@entity_type.manager' ] diff --git a/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php b/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php index 8132304..87497dc 100644 --- a/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php +++ b/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php @@ -4,6 +4,7 @@ namespace Drupal\simple_oauth\Authentication\Provider; use Drupal\Core\Authentication\AuthenticationProviderInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Session\PermissionCheckerInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\Path\PathValidatorInterface; use Drupal\Core\Routing\RouteProviderInterface; @@ -15,6 +16,7 @@ use League\OAuth2\Server\Exception\OAuthServerException; use Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface; use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; /** * OAuth2 authentication provider. @@ -36,90 +38,39 @@ class SimpleOauthAuthenticationProvider implements AuthenticationProviderInterfa use StringTranslationTrait; - /** - * The resource server factory. - * - * @var \Drupal\simple_oauth\Server\ResourceServerFactoryInterface - */ - protected ResourceServerFactoryInterface $resourceServerFactory; - - /** - * The entity type manager. - * - * @var \Drupal\Core\Entity\EntityTypeManagerInterface - */ - protected EntityTypeManagerInterface $entityTypeManager; - - /** - * The request policy. - * - * @var \Drupal\simple_oauth\PageCache\SimpleOauthRequestPolicyInterface - */ - protected SimpleOauthRequestPolicyInterface $oauthPageCacheRequestPolicy; - - /** - * The HTTP message factory. - * - * @var \Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface - */ - protected HttpMessageFactoryInterface $httpMessageFactory; - - /** - * The HTTP foundation factory. - * - * @var \Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface - */ - protected HttpFoundationFactoryInterface $httpFoundationFactory; - - /** - * The path validator service. - * - * @var \Drupal\Core\Path\PathValidatorInterface - */ - protected PathValidatorInterface $pathValidator; - - /** - * The route provider service. - * - * @var \Drupal\Core\Routing\RouteProviderInterface - */ - protected RouteProviderInterface $routeProvider; - /** * Constructs an HTTP basic authentication provider object. * - * @param \Drupal\simple_oauth\Server\ResourceServerFactoryInterface $resource_server_factory + * @param \Drupal\simple_oauth\Server\ResourceServerFactoryInterface $resourceServerFactory * The resource server factory. - * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager * The entity type manager service. - * @param \Drupal\simple_oauth\PageCache\SimpleOauthRequestPolicyInterface $page_cache_request_policy + * @param \Drupal\simple_oauth\PageCache\SimpleOauthRequestPolicyInterface $oauthPageCacheRequestPolicy * The page cache request policy. - * @param \Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface $http_message_factory + * @param \Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface $httpMessageFactory * The HTTP message factory. - * @param \Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface $http_foundation_factory + * @param \Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface $httpFoundationFactory * The HTTP foundation factory. * @param \Drupal\Core\Path\PathValidatorInterface $path_validator * The path validator service. * @param \Drupal\Core\Routing\RouteProviderInterface $route_provider * The route provider service. + * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack + * The request stack. + * @param \Drupal\Core\Session\PermissionCheckerInterface $permissionChecker + * The permission checker service. */ public function __construct( - ResourceServerFactoryInterface $resource_server_factory, - EntityTypeManagerInterface $entity_type_manager, - SimpleOauthRequestPolicyInterface $page_cache_request_policy, - HttpMessageFactoryInterface $http_message_factory, - HttpFoundationFactoryInterface $http_foundation_factory, - PathValidatorInterface $path_validator, - RouteProviderInterface $route_provider, - ) { - $this->resourceServerFactory = $resource_server_factory; - $this->entityTypeManager = $entity_type_manager; - $this->oauthPageCacheRequestPolicy = $page_cache_request_policy; - $this->httpMessageFactory = $http_message_factory; - $this->httpFoundationFactory = $http_foundation_factory; - $this->pathValidator = $path_validator; - $this->routeProvider = $route_provider; - } + protected readonly ResourceServerFactoryInterface $resourceServerFactory, + protected readonly EntityTypeManagerInterface $entityTypeManager, + protected readonly SimpleOauthRequestPolicyInterface $oauthPageCacheRequestPolicy, + protected readonly HttpMessageFactoryInterface $httpMessageFactory, + protected readonly HttpFoundationFactoryInterface $httpFoundationFactory, + protected readonly PathValidatorInterface $path_validator, + protected readonly RouteProviderInterface $route_provider, + protected readonly RequestStack $requestStack, + protected readonly PermissionCheckerInterface $permissionChecker, + ) {} /** * {@inheritdoc} @@ -175,8 +126,12 @@ class SimpleOauthAuthenticationProvider implements AuthenticationProviderInterfa 'value' => $auth_request->get('oauth_access_token_id'), ]); $token = reset($tokens); - - $account = new TokenAuthUser($token); + $account = new TokenAuthUser( + $this->permissionChecker, + $token, + $this->httpMessageFactory, + $this->requestStack + ); // Revoke the access token for the blocked user. if ($account->isBlocked() && $account->isAuthenticated()) { diff --git a/src/Authentication/TokenAuthUser.php b/src/Authentication/TokenAuthUser.php index 36853ea..f93f3a7 100644 --- a/src/Authentication/TokenAuthUser.php +++ b/src/Authentication/TokenAuthUser.php @@ -7,10 +7,13 @@ use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Session\AccountInterface; use Drupal\consumers\Entity\Consumer; +use Drupal\Core\Session\PermissionCheckerInterface; use Drupal\simple_oauth\Entity\Oauth2TokenInterface; use Drupal\user\Entity\User; use Drupal\user\UserInterface; use League\OAuth2\Server\Exception\OAuthServerException; +use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface; +use Symfony\Component\HttpFoundation\RequestStack; /** * The decorated user class with token information. @@ -26,13 +29,6 @@ class TokenAuthUser implements TokenAuthUserInterface { */ protected $subject; - /** - * The bearer token. - * - * @var \Drupal\simple_oauth\Entity\Oauth2TokenInterface - */ - protected Oauth2TokenInterface $token; - /** * The activated consumer instance. * @@ -43,24 +39,33 @@ class TokenAuthUser implements TokenAuthUserInterface { /** * Constructs a TokenAuthUser object. * + * @param \Drupal\Core\Session\PermissionCheckerInterface $permissionChecker + * The permission checker service. * @param \Drupal\simple_oauth\Entity\Oauth2TokenInterface $token * The underlying token. + * @param \Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface $httpMessageFactory + * The HTTP message factory. + * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack + * The request stack. * * @throws \League\OAuth2\Server\Exception\OAuthServerException * When there is no user. */ - public function __construct(Oauth2TokenInterface $token) { + public function __construct( + protected readonly PermissionCheckerInterface $permissionChecker, + protected readonly Oauth2TokenInterface $token, + protected readonly HttpMessageFactoryInterface $httpMessageFactory, + protected readonly RequestStack $requestStack, + ) { $this->consumer = $token->get('client')->entity; if (!$this->subject = $token->get('auth_user_id')->entity) { $this->subject = $this->consumer->get('user_id')->entity; } if (!$this->subject) { - $server_request = \Drupal::service('psr7.http_message_factory') - ->createRequest(\Drupal::request()); + $server_request = $httpMessageFactory->createRequest($requestStack->getCurrentRequest()); throw OAuthServerException::invalidClient($server_request); } - $this->token = $token; } /** @@ -81,18 +86,18 @@ class TokenAuthUser implements TokenAuthUserInterface { * {@inheritdoc} */ public function hasPermission($permission) { + if (!is_string($permission)) { + @trigger_error('Calling ' . __METHOD__ . '() with a $permission parameter of type other than string is deprecated in drupal:10.3.0 and will cause an error in drupal:11.0.0. See https://www.drupal.org/node/3411485', E_USER_DEPRECATED); + return FALSE; + } // When the 'auth_user_id' isn't available on the token (which can happen // with the 'client credentials' grant type): // has permission checks are then only performed on the scopes. if ($this->token->get('auth_user_id')->isEmpty()) { - return $this->token->hasPermission($permission); - } - // User #1 has all permissions. - if ((int) $this->id() === 1) { - return TRUE; + return $this->permissionChecker->hasPermission($permission, $this); } - return $this->token->hasPermission($permission) && $this->subject->hasPermission($permission); + return $this->permissionChecker->hasPermission($permission, $this) && $this->subject->hasPermission($permission); } /* --------------------------------------------------------------------------- diff --git a/src/Entity/Oauth2Scope.php b/src/Entity/Oauth2Scope.php index 618f318..7aeab01 100644 --- a/src/Entity/Oauth2Scope.php +++ b/src/Entity/Oauth2Scope.php @@ -257,6 +257,15 @@ class Oauth2Scope extends ConfigEntityBase implements Oauth2ScopeEntityInterface return $granularityCollection->get($this->granularity_id); } + /** + * {@inheritdoc} + */ + public function getPermissions(): array { + $granularity = $this->getGranularity(); + assert($granularity instanceof ScopeGranularityInterface); + return $granularity->getPermissions(); + } + /** * {@inheritdoc} */ diff --git a/src/Oauth2AccessPolicy.php b/src/Oauth2AccessPolicy.php new file mode 100644 index 0000000..ec29edf --- /dev/null +++ b/src/Oauth2AccessPolicy.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\simple_oauth; + +use Drupal\Core\Session\AccessPolicyBase; +use Drupal\Core\Session\AccountInterface; +use Drupal\Core\Session\CalculatedPermissionsItem; +use Drupal\Core\Session\RefinableCalculatedPermissionsInterface; +use Drupal\simple_oauth\Authentication\TokenAuthUserInterface; + +/** + * Grants permissions based on OAuth2 scopes. + */ +final class Oauth2AccessPolicy extends AccessPolicyBase { + + public function __construct(protected Oauth2ScopeProviderInterface $scopeProvider) {} + + /** + * {@inheritdoc} + */ + public function calculatePermissions(AccountInterface $account, string $scope): RefinableCalculatedPermissionsInterface { + $calculated_permissions = parent::calculatePermissions($account, $scope); + + if (!$account instanceof TokenAuthUserInterface) { + return $calculated_permissions; + } + + $token = $account->getToken(); + foreach ($token->get('scopes')->getScopes() as $oauth2_scope) { + $calculated_permissions + ->addItem(new CalculatedPermissionsItem($this->scopeProvider->getPermissions($oauth2_scope))) + ->addCacheableDependency($oauth2_scope); + } + + return $calculated_permissions; + } + + /** + * {@inheritdoc} + */ + public function getPersistentCacheContexts(): array { + return ['oauth2_scopes']; + } + +} diff --git a/src/Oauth2ScopeCacheContext.php b/src/Oauth2ScopeCacheContext.php new file mode 100644 index 0000000..d89ca2e --- /dev/null +++ b/src/Oauth2ScopeCacheContext.php @@ -0,0 +1,57 @@ +<?php + +namespace Drupal\simple_oauth; + +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Cache\Context\CalculatedCacheContextInterface; +use Drupal\Core\Session\AccountProxyInterface; +use Drupal\simple_oauth\Authentication\TokenAuthUserInterface; + +/** + * Defines the Oauth2ScopeCacheContext service, for "per scope" caching. + */ +class Oauth2ScopeCacheContext implements CalculatedCacheContextInterface { + + /** + * Constructs a new Oauth2ScopeCacheContext class. + * + * @param \Drupal\Core\Session\AccountProxyInterface $account + * The current user. + */ + public function __construct(protected AccountProxyInterface $account) {} + + /** + * {@inheritdoc} + */ + public static function getLabel() { + return t("OAuth2 scopes"); + } + + /** + * {@inheritdoc} + */ + public function getContext($oauth2_scope = NULL) { + $account = $this->account->getAccount(); + if (!$account instanceof TokenAuthUserInterface) { + return ''; + } + + $token = $account->getToken(); + $scope_names = array_map(function (Oauth2ScopeInterface $scope) { + return $scope->getName(); + }, $token->get('scopes')->getScopes()); + + if ($oauth2_scope === NULL) { + return implode(',', $scope_names); + } + return (in_array($oauth2_scope, $scope_names) ? 'true' : 'false'); + } + + /** + * {@inheritdoc} + */ + public function getCacheableMetadata($scope = NULL) { + return (new CacheableMetadata())->setCacheTags(['user:' . $this->account->id()]); + } + +} diff --git a/src/Oauth2ScopeInterface.php b/src/Oauth2ScopeInterface.php index 2c66196..d0e8125 100644 --- a/src/Oauth2ScopeInterface.php +++ b/src/Oauth2ScopeInterface.php @@ -99,4 +99,12 @@ interface Oauth2ScopeInterface { */ public function getGranularity(): ?ScopeGranularityInterface; + /** + * Get the referenced permissions. + * + * @return array + * Returns the permissions. + */ + public function getPermissions(): array; + } diff --git a/src/Oauth2ScopeProvider.php b/src/Oauth2ScopeProvider.php index fb64182..4b8913e 100644 --- a/src/Oauth2ScopeProvider.php +++ b/src/Oauth2ScopeProvider.php @@ -94,6 +94,25 @@ class Oauth2ScopeProvider implements Oauth2ScopeProviderInterface { return FALSE; } + /** + * {@inheritdoc} + */ + public function getPermissions(Oauth2ScopeInterface $scope): array { + if (!$scope->isUmbrella()) { + $granularity = $scope->getGranularity(); + assert($granularity instanceof ScopeGranularityInterface); + return $granularity->getPermissions(); + } + + $permissions = []; + $children = $this->loadChildren($scope->id()); + foreach ($children as $child) { + $permissions = array_unique(array_merge($permissions, $child->getPermissions())); + } + + return $permissions; + } + /** * Adds a permission to the flatten permission tree. * diff --git a/src/Oauth2ScopeProviderInterface.php b/src/Oauth2ScopeProviderInterface.php index 2b09874..53adc01 100644 --- a/src/Oauth2ScopeProviderInterface.php +++ b/src/Oauth2ScopeProviderInterface.php @@ -20,4 +20,12 @@ interface Oauth2ScopeProviderInterface extends Oauth2ScopeAdapterInterface { */ public function scopeHasPermission(string $permission, Oauth2ScopeInterface $scope): bool; + /** + * Get the referenced permissions. + * + * @return array + * Returns the permissions. + */ + public function getPermissions(Oauth2ScopeInterface $scope): array; + } diff --git a/src/Plugin/ScopeGranularity/Permission.php b/src/Plugin/ScopeGranularity/Permission.php index ba7c4e9..467cf1d 100644 --- a/src/Plugin/ScopeGranularity/Permission.php +++ b/src/Plugin/ScopeGranularity/Permission.php @@ -76,6 +76,13 @@ class Permission extends ScopeGranularityBase implements ContainerFactoryPluginI return $this->getConfiguration()['permission'] === $permission; } + /** + * {@inheritdoc} + */ + public function getPermissions(): array { + return [$this->getConfiguration()['permission']]; + } + /** * {@inheritdoc} */ diff --git a/src/Plugin/ScopeGranularity/Role.php b/src/Plugin/ScopeGranularity/Role.php index 924a736..805adc5 100644 --- a/src/Plugin/ScopeGranularity/Role.php +++ b/src/Plugin/ScopeGranularity/Role.php @@ -11,6 +11,7 @@ use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\simple_oauth\Attribute\ScopeGranularity; use Drupal\simple_oauth\Oauth2ScopeInterface; use Drupal\simple_oauth\Plugin\ScopeGranularityBase; +use Drupal\user\RoleInterface; use Drupal\user\RoleStorage; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -87,6 +88,33 @@ class Role extends ScopeGranularityBase implements ContainerFactoryPluginInterfa return $role_storage->isPermissionInRoles($permission, $rolesToCheck); } + /** + * {@inheritdoc} + */ + public function getPermissions(): array { + $role = $this->getConfiguration()['role']; + + $lockedRoles = [ + AccountInterface::AUTHENTICATED_ROLE, + AccountInterface::ANONYMOUS_ROLE, + ]; + $rolesToCheck = !in_array($role, $lockedRoles) + ? [AccountInterface::AUTHENTICATED_ROLE, $role] + : [$role]; + + $role_storage = $this->entityTypeManager->getStorage('user_role'); + assert($role_storage instanceof RoleStorage); + + $permissions = []; + foreach ($rolesToCheck as $roleToCheck) { + $role = $role_storage->load($roleToCheck); + assert($role instanceof RoleInterface); + $permissions = array_unique(array_merge($permissions, $role->getPermissions())); + } + + return $permissions; + } + /** * {@inheritdoc} */ diff --git a/src/Plugin/ScopeGranularityInterface.php b/src/Plugin/ScopeGranularityInterface.php index eb94b9b..36d6b3f 100644 --- a/src/Plugin/ScopeGranularityInterface.php +++ b/src/Plugin/ScopeGranularityInterface.php @@ -32,4 +32,12 @@ interface ScopeGranularityInterface extends ConfigurableInterface, PluginFormInt */ public function hasPermission(string $permission): bool; + /** + * Returns a list of permissions assigned to the scope. + * + * @return array + * The permissions assigned to the scope. + */ + public function getPermissions(): array; + } diff --git a/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php b/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php index e483fdb..619c0d9 100644 --- a/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php +++ b/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php @@ -58,6 +58,13 @@ class TestGranularity extends ScopeGranularityBase { return TRUE; } + /** + * {@inheritdoc} + */ + public function getPermissions(): array { + + } + /** * {@inheritdoc} */ diff --git a/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php b/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php index fbd771d..98c30dd 100644 --- a/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php +++ b/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php @@ -8,6 +8,7 @@ use Drupal\Core\PageCache\RequestPolicyInterface; use Drupal\Core\Path\PathValidatorInterface; use Drupal\Core\Routing\RouteProviderInterface; use Drupal\Core\Url; +use Drupal\Core\Session\PermissionCheckerInterface; use Drupal\TestTools\Random; use Drupal\Tests\UnitTestCase; use Drupal\simple_oauth\Authentication\Provider\SimpleOauthAuthenticationProvider; @@ -19,6 +20,7 @@ use Symfony\Component\Routing\Route; use Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface; use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; /** * @coversDefaultClass \Drupal\simple_oauth\Authentication\Provider\SimpleOauthAuthenticationProvider @@ -85,10 +87,11 @@ class SimpleOauthAuthenticationTest extends UnitTestCase { // Store the Prophecy mock and reveal it for the strongly typed property. $this->pathValidatorMock = $this->prophesize(PathValidatorInterface::class); $this->pathValidator = $this->pathValidatorMock->reveal(); - $this->routeProviderMock = $this->prophesize(RouteProviderInterface::class); $this->routeProvider = $this->routeProviderMock->reveal(); - + $request_stack = $this->prophesize(RequestStack::class); + $permission_checker = $this->prophesize(PermissionCheckerInterface::class); + $this->provider = new SimpleOauthAuthenticationProvider( $resource_server_factory->reveal(), $entity_type_manager->reveal(), @@ -96,7 +99,9 @@ class SimpleOauthAuthenticationTest extends UnitTestCase { $http_message_factory->reveal(), $http_foundation_factory->reveal(), $this->pathValidator, - $this->routeProvider + $this->routeProvider, + $request_stack->reveal(), + $permission_checker->reveal() ); } -- GitLab From a81b8e21da2880e0fe80910aa457c512ef15be60 Mon Sep 17 00:00:00 2001 From: Bojan Bogdanovic <info@bojanbogdanovic.nl> Date: Tue, 18 Feb 2025 16:01:17 +0100 Subject: [PATCH 02/10] Increase minimum core requirement --- .../simple_oauth_static_scope.info.yml | 2 +- simple_oauth.info.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/simple_oauth_static_scope/simple_oauth_static_scope.info.yml b/modules/simple_oauth_static_scope/simple_oauth_static_scope.info.yml index 4442743..53f8553 100644 --- a/modules/simple_oauth_static_scope/simple_oauth_static_scope.info.yml +++ b/modules/simple_oauth_static_scope/simple_oauth_static_scope.info.yml @@ -1,7 +1,7 @@ name: Simple OAuth static scope type: module description: 'Makes static (YAML) defined scopes available.' -core_version_requirement: ^10.2 || ^11 +core_version_requirement: ^10.3 || ^11 package: Authentication dependencies: - simple_oauth:simple_oauth diff --git a/simple_oauth.info.yml b/simple_oauth.info.yml index 346c0f0..4527812 100644 --- a/simple_oauth.info.yml +++ b/simple_oauth.info.yml @@ -1,7 +1,7 @@ name: Simple OAuth & OpenID Connect type: module description: 'The OAuth 2.0 Authorization Framework' -core_version_requirement: ^10.2 || ^11 +core_version_requirement: ^10.3 || ^11 package: Authentication configure: oauth2_token.settings dependencies: -- GitLab From 87c94ae4fc44b8d37b79ea073990a33b33bca7c4 Mon Sep 17 00:00:00 2001 From: Bojan Bogdanovic <info@bojanbogdanovic.nl> Date: Wed, 19 Feb 2025 10:21:54 +0100 Subject: [PATCH 03/10] Update static scope + granularity test plugin --- .../simple_oauth_static_scope/src/Plugin/Oauth2Scope.php | 9 +++++++++ .../src/Plugin/ScopeGranularity/TestGranularity.php | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/modules/simple_oauth_static_scope/src/Plugin/Oauth2Scope.php b/modules/simple_oauth_static_scope/src/Plugin/Oauth2Scope.php index 1424a88..608ec31 100644 --- a/modules/simple_oauth_static_scope/src/Plugin/Oauth2Scope.php +++ b/modules/simple_oauth_static_scope/src/Plugin/Oauth2Scope.php @@ -108,4 +108,13 @@ class Oauth2Scope extends PluginBase implements Oauth2ScopePluginInterface, Cont ); } + /** + * {@inheritdoc} + */ + public function getPermissions(): array { + $granularity = $this->getGranularity(); + assert($granularity instanceof ScopeGranularityInterface); + return $granularity->getPermissions(); + } + } diff --git a/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php b/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php index 619c0d9..f6843ef 100644 --- a/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php +++ b/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php @@ -62,7 +62,7 @@ class TestGranularity extends ScopeGranularityBase { * {@inheritdoc} */ public function getPermissions(): array { - + return []; } /** -- GitLab From 63feb7c27b3553e35d60bcc019ff1468176bcee8 Mon Sep 17 00:00:00 2001 From: Bojan Bogdanovic <info@bojanbogdanovic.nl> Date: Thu, 12 Jun 2025 11:33:58 +0200 Subject: [PATCH 04/10] Move all hasPermission logic to the access policy --- simple_oauth.services.yml | 2 +- src/Authentication/TokenAuthUser.php | 15 ++++++------ src/Authentication/TokenAuthUserInterface.php | 8 +++++++ src/Oauth2AccessPolicy.php | 24 +++++++++++++++++-- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/simple_oauth.services.yml b/simple_oauth.services.yml index f568b8b..f0eb95b 100644 --- a/simple_oauth.services.yml +++ b/simple_oauth.services.yml @@ -47,7 +47,7 @@ services: - { name: cache.context } access_policy.simple_oauth: class: Drupal\simple_oauth\Oauth2AccessPolicy - arguments: [ '@simple_oauth.oauth2_scope.provider' ] + arguments: [ '@simple_oauth.oauth2_scope.provider', '@entity_type.manager' ] tags: - { name: access_policy } simple_oauth.normalizer.oauth2_token: diff --git a/src/Authentication/TokenAuthUser.php b/src/Authentication/TokenAuthUser.php index f93f3a7..c52d25c 100644 --- a/src/Authentication/TokenAuthUser.php +++ b/src/Authentication/TokenAuthUser.php @@ -82,6 +82,13 @@ class TokenAuthUser implements TokenAuthUserInterface { return $this->consumer; } + /** + * {@inheritdoc} + */ + public function getSubject(): UserInterface { + return $this->subject; + } + /** * {@inheritdoc} */ @@ -90,14 +97,8 @@ class TokenAuthUser implements TokenAuthUserInterface { @trigger_error('Calling ' . __METHOD__ . '() with a $permission parameter of type other than string is deprecated in drupal:10.3.0 and will cause an error in drupal:11.0.0. See https://www.drupal.org/node/3411485', E_USER_DEPRECATED); return FALSE; } - // When the 'auth_user_id' isn't available on the token (which can happen - // with the 'client credentials' grant type): - // has permission checks are then only performed on the scopes. - if ($this->token->get('auth_user_id')->isEmpty()) { - return $this->permissionChecker->hasPermission($permission, $this); - } - return $this->permissionChecker->hasPermission($permission, $this) && $this->subject->hasPermission($permission); + return $this->permissionChecker->hasPermission($permission, $this); } /* --------------------------------------------------------------------------- diff --git a/src/Authentication/TokenAuthUserInterface.php b/src/Authentication/TokenAuthUserInterface.php index 315210f..cf13584 100644 --- a/src/Authentication/TokenAuthUserInterface.php +++ b/src/Authentication/TokenAuthUserInterface.php @@ -29,4 +29,12 @@ interface TokenAuthUserInterface extends \IteratorAggregate, UserInterface { */ public function getConsumer(): Consumer; + /** + * Get the decorated subject. + * + * @return \Drupal\user\UserInterface + * The original user object. + */ + public function getSubject(): UserInterface; + } diff --git a/src/Oauth2AccessPolicy.php b/src/Oauth2AccessPolicy.php index ec29edf..2d4b56e 100644 --- a/src/Oauth2AccessPolicy.php +++ b/src/Oauth2AccessPolicy.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\simple_oauth; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Session\AccessPolicyBase; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Session\CalculatedPermissionsItem; @@ -15,7 +16,7 @@ use Drupal\simple_oauth\Authentication\TokenAuthUserInterface; */ final class Oauth2AccessPolicy extends AccessPolicyBase { - public function __construct(protected Oauth2ScopeProviderInterface $scopeProvider) {} + public function __construct(protected Oauth2ScopeProviderInterface $scopeProvider, protected EntityTypeManagerInterface $entityTypeManager) {} /** * {@inheritdoc} @@ -28,9 +29,28 @@ final class Oauth2AccessPolicy extends AccessPolicyBase { } $token = $account->getToken(); + $user_permissions = []; + + // When the 'auth_user_id' isn't available on the token (which can happen + // with the 'client credentials' grant type): + // has permission checks are then only performed on the scopes. + if (!$token->get('auth_user_id')->isEmpty()) { + /** @var \Drupal\user\RoleInterface[] $user_roles */ + $user_roles = $this->entityTypeManager->getStorage('user_role')->loadMultiple($account->getSubject()->getRoles()); + foreach ($user_roles as $user_role) { + $user_permissions = array_merge($user_permissions, $user_role->getPermissions()); + } + } + foreach ($token->get('scopes')->getScopes() as $oauth2_scope) { + $permissions = $this->scopeProvider->getPermissions($oauth2_scope); + + if (!empty($user_permissions)) { + $permissions = array_intersect($permissions, $user_permissions); + } + $calculated_permissions - ->addItem(new CalculatedPermissionsItem($this->scopeProvider->getPermissions($oauth2_scope))) + ->addItem(new CalculatedPermissionsItem($permissions)) ->addCacheableDependency($oauth2_scope); } -- GitLab From ae91894e5348cbde83367d1faa24c1cc102e8d13 Mon Sep 17 00:00:00 2001 From: Bojan Bogdanovic <info@bojanbogdanovic.nl> Date: Thu, 12 Jun 2025 11:47:25 +0200 Subject: [PATCH 05/10] Correct vars --- .../Provider/SimpleOauthAuthenticationProvider.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php b/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php index 87497dc..d62e278 100644 --- a/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php +++ b/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php @@ -51,9 +51,9 @@ class SimpleOauthAuthenticationProvider implements AuthenticationProviderInterfa * The HTTP message factory. * @param \Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface $httpFoundationFactory * The HTTP foundation factory. - * @param \Drupal\Core\Path\PathValidatorInterface $path_validator + * @param \Drupal\Core\Path\PathValidatorInterface $pathValidator * The path validator service. - * @param \Drupal\Core\Routing\RouteProviderInterface $route_provider + * @param \Drupal\Core\Routing\RouteProviderInterface $routeProvider * The route provider service. * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack * The request stack. @@ -66,8 +66,8 @@ class SimpleOauthAuthenticationProvider implements AuthenticationProviderInterfa protected readonly SimpleOauthRequestPolicyInterface $oauthPageCacheRequestPolicy, protected readonly HttpMessageFactoryInterface $httpMessageFactory, protected readonly HttpFoundationFactoryInterface $httpFoundationFactory, - protected readonly PathValidatorInterface $path_validator, - protected readonly RouteProviderInterface $route_provider, + protected readonly PathValidatorInterface $pathValidator, + protected readonly RouteProviderInterface $routeProvider, protected readonly RequestStack $requestStack, protected readonly PermissionCheckerInterface $permissionChecker, ) {} -- GitLab From 89e3c4066afd67b835e26d4ee488885d51b0a768 Mon Sep 17 00:00:00 2001 From: Bojan Bogdanovic <info@bojanbogdanovic.nl> Date: Thu, 12 Jun 2025 11:52:00 +0200 Subject: [PATCH 06/10] Fix whitespace --- .../Authentication/Provider/SimpleOauthAuthenticationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php b/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php index 98c30dd..d38c985 100644 --- a/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php +++ b/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php @@ -91,7 +91,7 @@ class SimpleOauthAuthenticationTest extends UnitTestCase { $this->routeProvider = $this->routeProviderMock->reveal(); $request_stack = $this->prophesize(RequestStack::class); $permission_checker = $this->prophesize(PermissionCheckerInterface::class); - + $this->provider = new SimpleOauthAuthenticationProvider( $resource_server_factory->reveal(), $entity_type_manager->reveal(), -- GitLab From 23e42b94a46c607c8aa556d8e87c097bbca275df Mon Sep 17 00:00:00 2001 From: Bojan Bogdanovic <info@bojanbogdanovic.nl> Date: Tue, 17 Jun 2025 15:44:21 +0200 Subject: [PATCH 07/10] Added support for admin roles and test coverage --- src/Oauth2AccessPolicy.php | 15 +- src/Oauth2ScopeCacheContext.php | 2 +- tests/src/Unit/Oauth2AccessPolicyTest.php | 269 ++++++++++++++++++++++ 3 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 tests/src/Unit/Oauth2AccessPolicyTest.php diff --git a/src/Oauth2AccessPolicy.php b/src/Oauth2AccessPolicy.php index 2d4b56e..0a7b1c3 100644 --- a/src/Oauth2AccessPolicy.php +++ b/src/Oauth2AccessPolicy.php @@ -29,20 +29,31 @@ final class Oauth2AccessPolicy extends AccessPolicyBase { } $token = $account->getToken(); + $user_roles = []; $user_permissions = []; // When the 'auth_user_id' isn't available on the token (which can happen // with the 'client credentials' grant type): // has permission checks are then only performed on the scopes. if (!$token->get('auth_user_id')->isEmpty()) { + $user_role_ids = $account->getSubject()->getRoles(); /** @var \Drupal\user\RoleInterface[] $user_roles */ - $user_roles = $this->entityTypeManager->getStorage('user_role')->loadMultiple($account->getSubject()->getRoles()); + $user_roles = $this->entityTypeManager->getStorage('user_role')->loadMultiple($user_role_ids); foreach ($user_roles as $user_role) { $user_permissions = array_merge($user_permissions, $user_role->getPermissions()); } } + /** @var \Drupal\simple_oauth\Oauth2ScopeInterface $oauth2_scope */ foreach ($token->get('scopes')->getScopes() as $oauth2_scope) { + $is_admin = FALSE; + if ($oauth2_scope->getGranularity()->getPluginId() === Oauth2ScopeInterface::GRANULARITY_ROLE) { + $user_role = $user_roles[$oauth2_scope->getGranularity()->getConfiguration()['role']] ?? NULL; + if ($user_role) { + $is_admin = $user_role->isAdmin(); + } + } + $permissions = $this->scopeProvider->getPermissions($oauth2_scope); if (!empty($user_permissions)) { @@ -50,7 +61,7 @@ final class Oauth2AccessPolicy extends AccessPolicyBase { } $calculated_permissions - ->addItem(new CalculatedPermissionsItem($permissions)) + ->addItem(new CalculatedPermissionsItem($permissions, $is_admin)) ->addCacheableDependency($oauth2_scope); } diff --git a/src/Oauth2ScopeCacheContext.php b/src/Oauth2ScopeCacheContext.php index d89ca2e..a443b7e 100644 --- a/src/Oauth2ScopeCacheContext.php +++ b/src/Oauth2ScopeCacheContext.php @@ -50,7 +50,7 @@ class Oauth2ScopeCacheContext implements CalculatedCacheContextInterface { /** * {@inheritdoc} */ - public function getCacheableMetadata($scope = NULL) { + public function getCacheableMetadata($oauth2_scope = NULL) { return (new CacheableMetadata())->setCacheTags(['user:' . $this->account->id()]); } diff --git a/tests/src/Unit/Oauth2AccessPolicyTest.php b/tests/src/Unit/Oauth2AccessPolicyTest.php new file mode 100644 index 0000000..34b03e8 --- /dev/null +++ b/tests/src/Unit/Oauth2AccessPolicyTest.php @@ -0,0 +1,269 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\simple_oauth\Unit; + +use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\Context\CacheContextsManager; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Field\FieldItemListInterface; +use Drupal\Core\Session\AccessPolicyInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\Core\Session\CalculatedPermissionsItem; +use Drupal\Core\Session\RefinableCalculatedPermissions; +use Drupal\simple_oauth\Authentication\TokenAuthUserInterface; +use Drupal\simple_oauth\Entity\Oauth2Scope; +use Drupal\simple_oauth\Entity\Oauth2TokenInterface; +use Drupal\simple_oauth\Oauth2AccessPolicy; +use Drupal\simple_oauth\Oauth2ScopeInterface; +use Drupal\simple_oauth\Oauth2ScopeProviderInterface; +use Drupal\simple_oauth\Plugin\Field\FieldType\Oauth2ScopeReferenceItemListInterface; +use Drupal\simple_oauth\Plugin\ScopeGranularityInterface; +use Drupal\Tests\UnitTestCase; +use Drupal\user\RoleInterface; +use Drupal\user\RoleStorageInterface; +use Drupal\user\UserInterface; +use Prophecy\Argument; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * @coversDefaultClass \Drupal\simple_oauth\Oauth2AccessPolicy + * @group simple_oauth + */ +class Oauth2AccessPolicyTest extends UnitTestCase { + + /** + * The mocked scope provider service. + * + * @var \Drupal\simple_oauth\Oauth2ScopeProviderInterface|\Prophecy\Prophecy\ObjectProphecy + */ + protected $scopeProvider; + + /** + * The access policy to test. + * + * @var \Drupal\simple_oauth\Oauth2AccessPolicy + */ + protected $accessPolicy; + + /** + * The mocked entity type manager service. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface|\Prophecy\Prophecy\ObjectProphecy + */ + protected $entityTypeManager; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->scopeProvider = $this->prophesize(Oauth2ScopeProviderInterface::class); + $this->entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class); + $this->accessPolicy = new Oauth2AccessPolicy($this->scopeProvider->reveal(), $this->entityTypeManager->reveal()); + + $cache_context_manager = $this->prophesize(CacheContextsManager::class); + $cache_context_manager->assertValidTokens(Argument::any())->willReturn(TRUE); + + $container = $this->prophesize(ContainerInterface::class); + $container->get('cache_contexts_manager')->willReturn($cache_context_manager->reveal()); + \Drupal::setContainer($container->reveal()); + } + + /** + * @covers ::applies + */ + public function testApplies(): void { + $this->assertTrue($this->accessPolicy->applies(AccessPolicyInterface::SCOPE_DRUPAL)); + $this->assertFalse($this->accessPolicy->applies('another scope')); + $this->assertFalse($this->accessPolicy->applies($this->randomString())); + } + + /** + * Tests the calculatePermissions method. + * + * @param array $roles + * The available roles. + * @param bool $expect_admin_rights + * Whether to expect admin rights to be granted. + * @param array $scopes + * The scopes to grant the account. + * + * @covers ::calculatePermissions + * @dataProvider calculatePermissionsProvider + */ + public function testCalculatePermissions(array $roles, bool $expect_admin_rights, array $scopes): void { + $account = $this->prophesize(TokenAuthUserInterface::class); + $user = $this->prophesize(UserInterface::class); + $user->getRoles()->willReturn(array_keys($roles)); + $account->getSubject()->willReturn($user->reveal()); + $token = $this->prophesize(Oauth2TokenInterface::class); + $auth_id_field = $this->prophesize(FieldItemListInterface::class); + $auth_id_field->isEmpty()->willReturn(FALSE); + $token->get('auth_user_id')->willReturn($auth_id_field->reveal()); + $scopes_field = $this->prophesize(Oauth2ScopeReferenceItemListInterface::class); + + $total_permissions = $cache_tags = $mocked_scopes = $mocked_roles = []; + foreach ($scopes as $scope_id => $scope) { + $scope_permissions = []; + if ($scope['granularity'] === Oauth2ScopeInterface::GRANULARITY_PERMISSION) { + $scope_permissions = [$scope['granularity_configuration']['permission']]; + $total_permissions = array_merge($total_permissions, $scope_permissions); + } + elseif ($scope['granularity'] === Oauth2ScopeInterface::GRANULARITY_ROLE) { + $role = $scope['granularity_configuration']['role']; + $scope_permissions = $roles[$role]['permissions']; + $total_permissions = array_merge($total_permissions, $scope_permissions); + } + + $cache_tags[] = "oauth2_scopes.$scope_id"; + + $mocked_scope = $this->prophesize(Oauth2Scope::class); + $scope_granularity = $this->prophesize(ScopeGranularityInterface::class); + $scope_granularity->getPluginId()->willReturn($scope['granularity']); + $scope_granularity->getConfiguration()->willReturn($scope['granularity_configuration']); + $mocked_scope->getGranularity()->willReturn($scope_granularity->reveal()); + $mocked_scope->getCacheTags()->willReturn(["oauth2_scopes.$scope_id"]); + $mocked_scope->getCacheContexts()->willReturn([]); + $mocked_scope->getCacheMaxAge()->willReturn(Cache::PERMANENT); + $mocked_scopes[$scope_id] = $mocked_scope->reveal(); + $this->scopeProvider->getPermissions($mocked_scope->reveal())->willReturn($scope_permissions); + } + + $scopes_field->getScopes()->willReturn($mocked_scopes); + $token->get('scopes')->willReturn($scopes_field->reveal()); + $account->getToken()->willReturn($token->reveal()); + + foreach ($roles as $role_id => $role) { + $mocked_role = $this->prophesize(RoleInterface::class); + $mocked_role->getPermissions()->willReturn($role['permissions']); + $mocked_role->isAdmin()->willReturn($role['is_admin']); + $mocked_roles[$role_id] = $mocked_role->reveal(); + } + + $role_storage = $this->prophesize(RoleStorageInterface::class); + $role_storage->loadMultiple(array_keys($roles))->willReturn($mocked_roles); + $this->entityTypeManager->getStorage('user_role')->willReturn($role_storage->reveal()); + + $calculated_permissions = $this->accessPolicy->calculatePermissions($account->reveal(), AccessPolicyInterface::SCOPE_DRUPAL); + + if (!empty($roles)) { + $this->assertCount(1, $calculated_permissions->getItems(), 'Only one calculated permissions item was added.'); + $item = $calculated_permissions->getItem(); + + if ($expect_admin_rights) { + $this->assertSame([], $item->getPermissions()); + $this->assertTrue($item->isAdmin()); + } + else { + $this->assertSame($total_permissions, $item->getPermissions()); + $this->assertFalse($item->isAdmin()); + } + } + + $this->assertSame($cache_tags, $calculated_permissions->getCacheTags()); + $this->assertSame(['oauth2_scopes'], $calculated_permissions->getCacheContexts()); + $this->assertSame(Cache::PERMANENT, $calculated_permissions->getCacheMaxAge()); + } + + /** + * Data provider for testCalculatePermissions. + * + * @return array + * A list of test scenarios. + */ + public static function calculatePermissionsProvider(): array { + $cases['permission-scope'] = [ + 'roles' => [], + 'expect_admin_rights' => FALSE, + 'scopes' => [ + 'scope_bar' => [ + 'granularity' => Oauth2ScopeInterface::GRANULARITY_PERMISSION, + 'granularity_configuration' => [ + 'permission' => 'bar', + ], + ], + ], + ]; + $cases['role-and-permission-scope'] = [ + 'roles' => [ + 'role_foo' => [ + 'permissions' => ['foo'], + 'is_admin' => FALSE, + ], + 'role_bar_baz' => [ + 'permissions' => ['bar', 'baz'], + 'is_admin' => FALSE, + ], + ], + 'expect_admin_rights' => FALSE, + 'scopes' => [ + 'scope_bar_baz' => [ + 'granularity' => Oauth2ScopeInterface::GRANULARITY_ROLE, + 'granularity_configuration' => [ + 'role' => 'role_bar_baz', + ], + ], + 'scope_foo' => [ + 'granularity' => Oauth2ScopeInterface::GRANULARITY_PERMISSION, + 'granularity_configuration' => [ + 'permission' => 'foo', + ], + ], + ], + ]; + $cases['admin-role-scope'] = [ + 'roles' => [ + 'role_foo' => [ + 'permissions' => ['foo'], + 'is_admin' => FALSE, + ], + 'role_bar' => [ + 'permissions' => ['bar'], + 'is_admin' => TRUE, + ], + ], + 'expect_admin_rights' => TRUE, + 'scopes' => [ + 'scope_bar' => [ + 'granularity' => Oauth2ScopeInterface::GRANULARITY_ROLE, + 'granularity_configuration' => [ + 'role' => 'role_bar', + ], + ], + ], + ]; + return $cases; + } + + /** + * Tests the alterPermissions method. + * + * @covers ::alterPermissions + */ + public function testAlterPermissions(): void { + $account = $this->prophesize(AccountInterface::class); + + $calculated_permissions = new RefinableCalculatedPermissions(); + $calculated_permissions->addItem(new CalculatedPermissionsItem(['foo'])); + $calculated_permissions->addCacheTags(['bar']); + $calculated_permissions->addCacheContexts(['baz']); + + $this->accessPolicy->alterPermissions($account->reveal(), AccessPolicyInterface::SCOPE_DRUPAL, $calculated_permissions); + $this->assertSame(['foo'], $calculated_permissions->getItem()->getPermissions()); + $this->assertSame(['bar'], $calculated_permissions->getCacheTags()); + $this->assertSame(['baz'], $calculated_permissions->getCacheContexts()); + } + + /** + * Tests the getPersistentCacheContexts method. + * + * @covers ::getPersistentCacheContexts + */ + public function testGetPersistentCacheContexts(): void { + $this->assertSame(['oauth2_scopes'], $this->accessPolicy->getPersistentCacheContexts(AccessPolicyInterface::SCOPE_DRUPAL)); + } + +} -- GitLab From efb3621ef7bbdda318bc88682606bf5ea70980fa Mon Sep 17 00:00:00 2001 From: Bojan Bogdanovic <info@bojanbogdanovic.nl> Date: Wed, 18 Jun 2025 07:26:00 +0200 Subject: [PATCH 08/10] Add cache context test coverage --- .../src/Unit/Oauth2ScopeCacheContextTest.php | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/src/Unit/Oauth2ScopeCacheContextTest.php diff --git a/tests/src/Unit/Oauth2ScopeCacheContextTest.php b/tests/src/Unit/Oauth2ScopeCacheContextTest.php new file mode 100644 index 0000000..35a1919 --- /dev/null +++ b/tests/src/Unit/Oauth2ScopeCacheContextTest.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\simple_oauth\Unit; + +use Drupal\Core\Session\AccountProxyInterface; +use Drupal\simple_oauth\Authentication\TokenAuthUserInterface; +use Drupal\simple_oauth\Entity\Oauth2TokenInterface; +use Drupal\simple_oauth\Oauth2ScopeCacheContext; +use Drupal\simple_oauth\Oauth2ScopeInterface; +use Drupal\simple_oauth\Plugin\Field\FieldType\Oauth2ScopeReferenceItemListInterface; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\simple_oauth\Oauth2ScopeCacheContext + * @group Cache + */ +class Oauth2ScopeCacheContextTest extends UnitTestCase { + + /** + * @covers ::getContext + */ + public function testCalculatedScope(): void { + $account = $this->prophesize(AccountProxyInterface::class); + $token_auth_user = $this->prophesize(TokenAuthUserInterface::class); + $token = $this->prophesize(Oauth2TokenInterface::class); + $scopes_field = $this->prophesize(Oauth2ScopeReferenceItemListInterface::class); + + $scopes = []; + foreach (['scope1', 'scope2'] as $scope_name) { + $scope = $this->prophesize(Oauth2ScopeInterface::class); + $scope->getName()->willReturn($scope_name); + $scopes[] = $scope->reveal(); + } + + $scopes_field->getScopes()->willReturn($scopes); + $token->get('scopes')->willReturn($scopes_field->reveal()); + $token_auth_user->getToken()->willReturn($token->reveal()); + $account->getAccount()->willReturn($token_auth_user->reveal()); + + $account->id()->willReturn(2); + $cache_context = new Oauth2ScopeCacheContext($account->reveal()); + $this->assertSame('true', $cache_context->getContext('scope1')); + $this->assertSame('true', $cache_context->getContext('scope2')); + $this->assertSame('false', $cache_context->getContext('scope3')); + $this->assertSame('scope1,scope2', $cache_context->getContext()); + } + +} -- GitLab From c19b0744b3f4750935c8927f6d9a8fa92b612507 Mon Sep 17 00:00:00 2001 From: Bojan Bogdanovic <info@bojanbogdanovic.nl> Date: Mon, 23 Jun 2025 11:52:04 +0200 Subject: [PATCH 09/10] Add Kernel test for the SuperUser case --- tests/src/Kernel/SuperUserTest.php | 112 +++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/src/Kernel/SuperUserTest.php diff --git a/tests/src/Kernel/SuperUserTest.php b/tests/src/Kernel/SuperUserTest.php new file mode 100644 index 0000000..11675a7 --- /dev/null +++ b/tests/src/Kernel/SuperUserTest.php @@ -0,0 +1,112 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\simple_oauth\Kernel; + +use Drupal\Core\Url; +use Drupal\entity_test\Entity\EntityTest; +use Drupal\user\Entity\Role; +use Drupal\user\RoleInterface; +use GuzzleHttp\Psr7\Query; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * Test case for getting all permissions as a super user with auth code. + * + * @group simple_oauth + */ +class SuperUserTest extends AuthorizedRequestBase { + + /** + * {@inheritdoc} + */ + protected bool $usesSuperUserAccessPolicy = TRUE; + + /** + * The test entity. + * + * @var \Drupal\Core\Entity\EntityInterface + */ + protected $entity; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), [ + 'grant simple_oauth codes', + ]); + + $this->client->set('automatic_authorization', TRUE); + $this->client->save(); + $current_user = $this->container->get('current_user'); + $current_user->setAccount($this->user); + + $this->entity = EntityTest::create(); + $this->entity->save(); + } + + /** + * Tests the super user access policy grants all permissions. + */ + public function testSuperUser(): void { + // Check if we are dealing with the super user. + $this->assertEquals('1', $this->user->id()); + + $response = $this->getAuthenticatedEntityResponse(); + $this->assertEquals(200, $response->getStatusCode()); + + // Turn off the super user access policy and try again. + $this->usesSuperUserAccessPolicy = FALSE; + $this->bootKernel(); + $this->setUp(); + + $response = $this->getAuthenticatedEntityResponse(); + $this->assertEquals(403, $response->getStatusCode()); + } + + /** + * Get the authenticated entity request response. + * + * @return \Symfony\Component\HttpFoundation\Response + * Returns the response. + * + * @throws \Drupal\Core\Entity\EntityMalformedException + */ + private function getAuthenticatedEntityResponse(): Response { + $parameters = [ + 'response_type' => 'code', + 'client_id' => $this->client->getClientId(), + 'client_secret' => $this->clientSecret, + 'scope' => $this->scope, + 'redirect_uri' => $this->redirectUri, + ]; + $authorize_url = Url::fromRoute('oauth2_token.authorize')->toString(); + $request = Request::create($authorize_url, 'GET', $parameters); + $response = $this->httpKernel->handle($request); + $parsed_url = parse_url($response->headers->get('location')); + $parsed_query = Query::parse($parsed_url['query']); + $code = $parsed_query['code']; + $parameters = [ + 'grant_type' => 'authorization_code', + 'client_id' => $this->client->getClientId(), + 'client_secret' => $this->clientSecret, + 'code' => $code, + 'scope' => $this->scope, + 'redirect_uri' => $this->redirectUri, + ]; + $request = Request::create($this->url->toString(), 'POST', $parameters); + $response = $this->httpKernel->handle($request); + $parsed_response = $this->assertValidTokenResponse($response, TRUE); + $access_token = $parsed_response['access_token']; + $request = Request::create($this->entity->toUrl()->toString()); + $request->headers->add(['Authorization' => "Bearer {$access_token}"]); + + return $this->httpKernel->handle($request); + } + +} -- GitLab From 96edf3a7075a4aeccb3eee4570ea5d3d96c48967 Mon Sep 17 00:00:00 2001 From: Bojan Bogdanovic <info@bojanbogdanovic.nl> Date: Mon, 30 Jun 2025 09:16:06 +0200 Subject: [PATCH 10/10] Granularity can be null, correct statement --- src/Oauth2AccessPolicy.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Oauth2AccessPolicy.php b/src/Oauth2AccessPolicy.php index 0a7b1c3..fecc476 100644 --- a/src/Oauth2AccessPolicy.php +++ b/src/Oauth2AccessPolicy.php @@ -47,7 +47,7 @@ final class Oauth2AccessPolicy extends AccessPolicyBase { /** @var \Drupal\simple_oauth\Oauth2ScopeInterface $oauth2_scope */ foreach ($token->get('scopes')->getScopes() as $oauth2_scope) { $is_admin = FALSE; - if ($oauth2_scope->getGranularity()->getPluginId() === Oauth2ScopeInterface::GRANULARITY_ROLE) { + if ($oauth2_scope->getGranularity()?->getPluginId() === Oauth2ScopeInterface::GRANULARITY_ROLE) { $user_role = $user_roles[$oauth2_scope->getGranularity()->getConfiguration()['role']] ?? NULL; if ($user_role) { $is_admin = $user_role->isAdmin(); -- GitLab