diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..355eefe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI +on: [push, pull_request] + +jobs: + tests: + name: PHPUnit PHP ${{ matrix.php }} ${{ matrix.dependency }} (Symfony ${{ matrix.symfony }}) + runs-on: ubuntu-22.04 + strategy: + matrix: + php: + - '8.1' + - '8.2' + - '8.3' + symfony: + - '5.4.*' + - '6.4.*' + - '7.1.*' + exclude: + - php: '8.1' + symfony: '7.1.*' + fail-fast: true + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + + - name: Configure Symfony + run: composer config extra.symfony.require "${{ matrix.symfony }}" + + - name: Update project dependencies + run: composer update --no-progress --ansi --prefer-stable + + - name: Validate composer + run: composer validate --strict --no-check-lock + + - name: PHP-CS-Fixer + run: vendor/bin/php-cs-fixer check -vv + + - name: PHPStan + run: vendor/bin/phpstan analyse + + - name: Run tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a98629 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea +vendor/ +composer.lock +phpunit.xml +.phpunit.cache +.php_cs.cache +.env diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..5c2c502 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,15 @@ +in(__DIR__ . '/src') + ->in(__DIR__ . '/tests') +; + +return Retailcrm\PhpCsFixer\Defaults::rules([ + 'declare_strict_types' => true, +]) + ->setFinder($finder) + ->setCacheFile(__DIR__ . '/.php_cs.cache/results') +; diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f8039f7 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +PHP=php + +vendor: composer.json + @$(PHP) composer install -o -n --no-ansi + @touch vendor || true + +phpunit: vendor + @$(PHP) vendor/bin/phpunit --color=always + +php-cs: vendor + @$(PHP) vendor/bin/php-cs-fixer check -vv + +phpstan: vendor + @$(PHP) vendor/bin/phpstan analyse + +check: php-cs phpunit diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d89235f --- /dev/null +++ b/composer.json @@ -0,0 +1,39 @@ +{ + "name": "retailcrm/oauth-server-bundle", + "type": "library", + "license": "proprietary", + "description": "OAuth Server Bundle", + "require": { + "php": ">=8.1", + "doctrine/orm": "^2.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/security-bundle": "^5.4|^6.0|^7.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.59", + "phpstan/phpstan": "^1.11", + "phpunit/phpunit": "^10.0", + "retailcrm/php-code-style": "^1.0" + }, + "autoload": { + "psr-4": { + "OAuth\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "OAuth\\Tests\\": "tests" + } + }, + "support": { + "email": "support@retailcrm.ru" + }, + "config": { + "sort-packages": true + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..ad87384 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + excludePaths: + - src/DependencyInjection/Configuration.php + level: 5 + paths: + - src diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..b749864 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + tests + + + + + + src + + + diff --git a/src/Command/CleanCommand.php b/src/Command/CleanCommand.php new file mode 100644 index 0000000..efa1d6f --- /dev/null +++ b/src/Command/CleanCommand.php @@ -0,0 +1,43 @@ +accessTokenStorage, $this->refreshTokenStorage, $this->authCodeStorage] as $service) { + if (!$service instanceof DeleteExpiredStorageInterface) { + throw new \LogicException(sprintf('The service "%s" must implement "%s".', $service::class, DeleteExpiredStorageInterface::class)); + } + + $result = $service->deleteExpired(); + $output->writeln(sprintf('Removed %d items from %s storage.', $result, $service::class)); + } + + return Command::SUCCESS; + } +} diff --git a/src/Command/CreateClientCommand.php b/src/Command/CreateClientCommand.php new file mode 100644 index 0000000..b85ed64 --- /dev/null +++ b/src/Command/CreateClientCommand.php @@ -0,0 +1,68 @@ +addOption( + 'redirect-uri', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Sets redirect uri for client. Use this option multiple times to set multiple redirect URIs.', + null + ) + ->addOption( + 'grant-type', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Sets allowed grant type for client. Use this option multiple times to set multiple grant types..', + null + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->title('Client Credentials'); + + $client = $this->clientStorage->createClient(); + + $client->setRedirectUris($input->getOption('redirect-uri')); + $client->setGrantTypes($input->getOption('grant-type')); + + $this->clientStorage->updateClient($client); + + $io->table(['Client ID', 'Client Secret'], [ + [$client->getPublicId(), $client->getSecret()], + ]); + + return 0; + } +} diff --git a/src/Controller/TokenController.php b/src/Controller/TokenController.php new file mode 100644 index 0000000..df7da69 --- /dev/null +++ b/src/Controller/TokenController.php @@ -0,0 +1,26 @@ +handler->grantAccessToken($request); + } catch (OAuthServerException $exception) { + return $exception->getHttpResponse(); + } + } +} diff --git a/src/DependencyInjection/Compiler/GrantExtensionsCompilerPass.php b/src/DependencyInjection/Compiler/GrantExtensionsCompilerPass.php new file mode 100644 index 0000000..73a69f0 --- /dev/null +++ b/src/DependencyInjection/Compiler/GrantExtensionsCompilerPass.php @@ -0,0 +1,28 @@ +findDefinition('oauth_server.grant_extension.custom'); + + foreach ($container->findTaggedServiceIds('oauth_server.grant_extension') as $id => $tags) { + foreach ($tags as $tag) { + if (empty($tag['uri'])) { + throw new InvalidArgumentException(sprintf('Service "%s" must define the "uri" attribute on "oauth_server.grant_extension" tags.', $id)); + } + + $customGrantDefinition->addMethodCall('addExtension', [$tag['uri'], new Reference($id)]); + } + } + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 0000000..347cc52 --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,89 @@ +getRootNode(); + + $rootNode + ->children() + ->scalarNode('client_class') + ->isRequired() + ->cannotBeEmpty() + ->validate() + ->always(function ($v) { + if (!is_subclass_of($v, ClientInterface::class)) { + throw new \InvalidArgumentException(sprintf('The client class must implement %s', ClientInterface::class)); + } + + return $v; + }) + ->end() + ->end() + ->scalarNode('access_token_class') + ->isRequired() + ->cannotBeEmpty() + ->validate() + ->always(function ($v) { + if (!is_subclass_of($v, AccessTokenInterface::class)) { + throw new \InvalidArgumentException(sprintf('The client class must implement %s', AccessTokenInterface::class)); + } + + return $v; + }) + ->end() + ->end() + ->scalarNode('refresh_token_class') + ->isRequired() + ->cannotBeEmpty() + ->validate() + ->always(function ($v) { + if (!is_subclass_of($v, RefreshTokenInterface::class)) { + throw new \InvalidArgumentException(sprintf('The client class must implement %s', RefreshTokenInterface::class)); + } + + return $v; + }) + ->end() + ->end() + ->scalarNode('auth_code_class') + ->isRequired() + ->cannotBeEmpty() + ->validate() + ->always(function ($v) { + if (!is_subclass_of($v, AuthCodeInterface::class)) { + throw new \InvalidArgumentException(sprintf('The client class must implement %s', AuthCodeInterface::class)); + } + + return $v; + }) + ->end() + ->end() + ->scalarNode('user_provider') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->arrayNode('options') + ->treatNullLike([]) + ->useAttributeAsKey('key') + ->prototype('variable')->end() + ->end() + ->end() + ; + + return $treeBuilder; + } +} diff --git a/src/DependencyInjection/OAuthServerExtension.php b/src/DependencyInjection/OAuthServerExtension.php new file mode 100644 index 0000000..a97407c --- /dev/null +++ b/src/DependencyInjection/OAuthServerExtension.php @@ -0,0 +1,40 @@ +processConfiguration($configuration, $configs); + + $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); + + $container->setParameter('oauth_server.config.options', $config['options']); + $container->setParameter('oauth_server.config.client_class', $config['client_class']); + $container->setParameter('oauth_server.config.access_token_class', $config['access_token_class']); + $container->setParameter('oauth_server.config.refresh_token_class', $config['refresh_token_class']); + $container->setParameter('oauth_server.config.auth_code_class', $config['auth_code_class']); + + $container->setAlias('oauth_server.user_provider', new Alias($config['user_provider'], false)); + + $loader->load('doctrine_storage.php'); + $loader->load('grant_extension.php'); + $loader->load('bearer_token.php'); + $loader->load('token_generator.php'); + $loader->load('handler.php'); + $loader->load('command.php'); + $loader->load('security.php'); + } +} diff --git a/src/DependencyInjection/Security/Factory/OAuthFactory.php b/src/DependencyInjection/Security/Factory/OAuthFactory.php new file mode 100644 index 0000000..3c7fa22 --- /dev/null +++ b/src/DependencyInjection/Security/Factory/OAuthFactory.php @@ -0,0 +1,64 @@ + new Reference($firewallName), $firewallAuthenticationProviders); + + $container + ->setDefinition($managerId = 'security.authenticator.oauth.' . $firewallName, new ChildDefinition('oauth_server.security.authenticator')) + ->addTag('monolog.logger', ['channel' => 'security']) + ; + + $managerLocator = $container->getDefinition('security.authenticator.managers_locator'); + $managerLocator->replaceArgument(0, array_merge($managerLocator->getArgument(0), [$firewallName => new ServiceClosureArgument(new Reference($managerId))])); + + $container + ->setDefinition('security.firewall.authenticator.' . $firewallName, new ChildDefinition('security.firewall.authenticator')) + ->replaceArgument(0, new Reference($managerId)) + ; + + $container + ->setDefinition('security.listener.user_checker.' . $firewallName, new ChildDefinition('security.listener.user_checker')) + ->replaceArgument(0, new Reference('security.user_checker.' . $firewallName)) + ->addTag('kernel.event_subscriber', ['dispatcher' => $firewallEventDispatcherId]) + ; + + if ($container->hasDefinition('security.command.debug_firewall')) { + $debugCommand = $container->getDefinition('security.command.debug_firewall'); + $debugCommand->replaceArgument(3, array_merge($debugCommand->getArgument(3), [$firewallName => $authenticators])); + } + + return $authenticatorId; + } + + public function getKey(): string + { + return 'oauth'; + } + + public function addConfiguration(NodeDefinition $builder): void + { + } + + public function getPriority(): int + { + return 0; + } +} diff --git a/src/Doctrine/Repository/AccessTokenRepositoryInterface.php b/src/Doctrine/Repository/AccessTokenRepositoryInterface.php new file mode 100644 index 0000000..44a49e7 --- /dev/null +++ b/src/Doctrine/Repository/AccessTokenRepositoryInterface.php @@ -0,0 +1,17 @@ +em->getRepository($className); + + if (!$repository instanceof AccessTokenRepositoryInterface) { + throw new \InvalidArgumentException(sprintf('The repository must implement %s', AccessTokenRepositoryInterface::class)); + } + + $this->repository = $repository; + } + + public function getAccessToken(string $token): ?AccessTokenInterface + { + return $this->repository->findByToken($token); + } + + public function createAccessToken(string $oauthToken, ClientInterface $client, ?UserInterface $user, int $expires, ?string $scope = null): void + { + $token = $this->repository->createAccessToken($client); + + $token + ->setToken($oauthToken) + ->setUser($user) + ->setExpiresAt($expires) + ->setScope($scope) + ; + + $this->repository->updateAccessToken($token); + } + + public function deleteExpired(): int + { + return $this->repository->deleteTokenExpired(); + } +} diff --git a/src/Doctrine/Storage/AuthCodeStorage.php b/src/Doctrine/Storage/AuthCodeStorage.php new file mode 100644 index 0000000..693d88b --- /dev/null +++ b/src/Doctrine/Storage/AuthCodeStorage.php @@ -0,0 +1,65 @@ +em->getRepository($className); + + if (!$repository instanceof AuthCodeRepositoryInterface) { + throw new \InvalidArgumentException(sprintf('The repository must implement %s', AuthCodeRepositoryInterface::class)); + } + + $this->repository = $repository; + } + + public function getAuthCode(string $code): ?AuthCodeInterface + { + return $this->repository->findByCode($code); + } + + public function createAuthCode(string $code, ClientInterface $client, ?UserInterface $user, string $redirectUri, int $expires, ?string $scope = null): void + { + $authCode = $this->repository->createAuthCode($client); + + $authCode + ->setRedirectUri($redirectUri) + ->setUser($user) + ->setToken($code) + ->setClient($client) + ->setExpiresAt($expires) + ->setScope($scope) + ; + + $this->repository->updateAuthCode($authCode); + } + + public function markAuthCodeAsUsed(string $code): void + { + $authCode = $this->repository->findByCode($code); + + if (null !== $authCode) { + $this->repository->deleteAuthCode($authCode); + } + } + + public function deleteExpired(): int + { + return $this->repository->deleteTokenExpired(); + } +} diff --git a/src/Doctrine/Storage/ClientStorage.php b/src/Doctrine/Storage/ClientStorage.php new file mode 100644 index 0000000..d5c6e4b --- /dev/null +++ b/src/Doctrine/Storage/ClientStorage.php @@ -0,0 +1,43 @@ +em->getRepository($className); + + if (!$repository instanceof ClientRepositoryInterface) { + throw new \InvalidArgumentException(sprintf('The repository must implement %s', ClientRepositoryInterface::class)); + } + + $this->repository = $repository; + } + + public function createClient(): ClientInterface + { + return $this->repository->createClient(); + } + + public function updateClient(ClientInterface $client): void + { + $this->repository->updateClient($client); + } + + public function getClient(string $pubicId): ?ClientInterface + { + return $this->repository->findByPublicId($pubicId); + } +} diff --git a/src/Doctrine/Storage/RefreshTokenStorage.php b/src/Doctrine/Storage/RefreshTokenStorage.php new file mode 100644 index 0000000..855d24c --- /dev/null +++ b/src/Doctrine/Storage/RefreshTokenStorage.php @@ -0,0 +1,62 @@ +em->getRepository($className); + + if (!$repository instanceof RefreshTokenRepositoryInterface) { + throw new \InvalidArgumentException(sprintf('The repository must implement %s', RefreshTokenRepositoryInterface::class)); + } + + $this->repository = $repository; + } + + public function getRefreshToken(string $refreshToken): ?RefreshTokenInterface + { + return $this->repository->findByToken($refreshToken); + } + + public function createRefreshToken(string $refreshToken, ClientInterface $client, ?UserInterface $user, int $expires, ?string $scope = null): void + { + $token = $this->repository->createRefreshToke($client); + $token + ->setToken($refreshToken) + ->setUser($user) + ->setExpiresAt($expires) + ->setScope($scope) + ; + + $this->repository->updateRefreshToke($token); + } + + public function unsetRefreshToken(string $refreshToken): void + { + $token = $this->repository->findByToken($refreshToken); + + if (null !== $token) { + $this->repository->deleteRefreshToke($token); + } + } + + public function deleteExpired(): int + { + return $this->repository->deleteTokenExpired(); + } +} diff --git a/src/Enum/ErrorCode.php b/src/Enum/ErrorCode.php new file mode 100644 index 0000000..9ada20a --- /dev/null +++ b/src/Enum/ErrorCode.php @@ -0,0 +1,67 @@ +grantType; + } + + public function getStored(): array + { + return $this->stored; + } +} diff --git a/src/Exception/OAuthAuthenticateException.php b/src/Exception/OAuthAuthenticateException.php new file mode 100644 index 0000000..5f4cce1 --- /dev/null +++ b/src/Exception/OAuthAuthenticateException.php @@ -0,0 +1,51 @@ +errorData['scope'] = $scope; + } + + $header = sprintf('%s realm=%s', ucwords($tokenType), $this->quote($realm)); + + foreach ($this->errorData as $key => $value) { + $header .= sprintf(', %s=%s', $key, $this->quote($value)); + } + + $this->header = ['WWW-Authenticate' => $header]; + } + + public function getResponseHeaders(): array + { + return array_merge($this->header, parent::getResponseHeaders()); + } + + /** + * @see http://tools.ietf.org/html/draft-ietf-httpbis-p1-messaging-17#section-3.2.3 + */ + private function quote(string $text): string + { + $text = preg_replace('~[^\\x21-\\x7E\\x80-\\xFF \\t]~x', '', $text); + + return '"' . addcslashes($text, '"\\') . '"'; + } +} diff --git a/src/Exception/OAuthException.php b/src/Exception/OAuthException.php new file mode 100644 index 0000000..1c0666c --- /dev/null +++ b/src/Exception/OAuthException.php @@ -0,0 +1,33 @@ +result = $result; + + $code = $result['code'] ?? self::UNKNOWN_CODE; + $message = $result['error'] ?? $result['message'] ?? self::UNKNOWN_ERROR; + + parent::__construct($message, $code); + } + + public function getResult(): array + { + return $this->result; + } + + public function getType(): string + { + return is_string($this->result['error'] ?? null) ? $this->result['error'] : 'Exception'; + } +} diff --git a/src/Exception/OAuthRedirectException.php b/src/Exception/OAuthRedirectException.php new file mode 100644 index 0000000..fd694b2 --- /dev/null +++ b/src/Exception/OAuthRedirectException.php @@ -0,0 +1,41 @@ +redirectUri = $redirectUri; + $this->method = $method; + if ($state) { + $this->errorData['state'] = $state; + } + } + + public function getResponseHeaders(): array + { + return [ + 'Location' => Uri::build($this->redirectUri, [$this->method->value => $this->errorData]), + ]; + } +} diff --git a/src/Exception/OAuthServerException.php b/src/Exception/OAuthServerException.php new file mode 100644 index 0000000..00b5d53 --- /dev/null +++ b/src/Exception/OAuthServerException.php @@ -0,0 +1,65 @@ +httpCode = $httpCode; + + $this->errorData['error'] = $error; + $this->errorData['error_description'] = $errorDescription; + } + + public function getHttpCode(): int + { + return $this->httpCode; + } + + public function getDescription(): ?string + { + return $this->errorData['error_description']; + } + + /** + * @see http://tools.ietf.org/html/draft-ietf-oauth-v2-20#section-5.1 + * @see http://tools.ietf.org/html/draft-ietf-oauth-v2-20#section-5.2 + */ + public function getHttpResponse(): Response + { + return new Response( + $this->getResponseBody(), + $this->getHttpCode(), + $this->getResponseHeaders() + ); + } + + /** + * @see http://tools.ietf.org/html/draft-ietf-oauth-v2-20#section-5.2 + * + * @return array + */ + public function getResponseHeaders(): array + { + return [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-store', + 'Pragma' => 'no-cache', + ]; + } + + public function getResponseBody(): string + { + return json_encode($this->errorData, JSON_THROW_ON_ERROR); + } +} diff --git a/src/Model/AccessToken.php b/src/Model/AccessToken.php new file mode 100644 index 0000000..04fa3fb --- /dev/null +++ b/src/Model/AccessToken.php @@ -0,0 +1,9 @@ +getGrantTypes(), true); + } + + public function checkSecret(?string $secret): bool + { + return null === $this->getSecret() || $secret === $this->getSecret(); + } +} diff --git a/src/Model/ClientInterface.php b/src/Model/ClientInterface.php new file mode 100644 index 0000000..dd283d6 --- /dev/null +++ b/src/Model/ClientInterface.php @@ -0,0 +1,28 @@ + $this->getExpiresAt(); + } +} diff --git a/src/Model/TokenInterface.php b/src/Model/TokenInterface.php new file mode 100644 index 0000000..0bfdfd7 --- /dev/null +++ b/src/Model/TokenInterface.php @@ -0,0 +1,32 @@ +getExtension('security'); + $extension->addAuthenticatorFactory(new OAuthFactory()); + $container->addCompilerPass(new GrantExtensionsCompilerPass()); + } +} diff --git a/src/Resources/config/bearer_token.php b/src/Resources/config/bearer_token.php new file mode 100644 index 0000000..b91fbaa --- /dev/null +++ b/src/Resources/config/bearer_token.php @@ -0,0 +1,38 @@ +services(); + + $services + ->set('oauth_server.bearer_token.form_extractor', FormExtractor::class) + ->alias(FormExtractor::class, 'oauth_server.bearer_token.form_extractor') + ; + + $services + ->set('oauth_server.bearer_token.header_extractor', HeaderExtractor::class) + ->alias(HeaderExtractor::class, 'oauth_server.bearer_token.header_extractor') + ; + + $services + ->set('oauth_server.bearer_token.query_extractor', QueryExtractor::class) + ->alias(QueryExtractor::class, 'oauth_server.bearer_token.query_extractor') + ; + + $services + ->set('oauth_server.bearer_token.chain_extractor', ChainExtractor::class) + ->args([ + [service(HeaderExtractor::class), service(FormExtractor::class), service(QueryExtractor::class)], + ]) + ->alias(ChainExtractor::class, 'oauth_server.bearer_token.chain_extractor') + ; +}; diff --git a/src/Resources/config/command.php b/src/Resources/config/command.php new file mode 100644 index 0000000..93c3f1c --- /dev/null +++ b/src/Resources/config/command.php @@ -0,0 +1,31 @@ +services(); + + $services + ->set('oauth_server.command.clean', CleanCommand::class) + ->args([ + service('oauth_server.doctrine_storage.access_token'), + service('oauth_server.doctrine_storage.refresh_token'), + service('oauth_server.doctrine_storage.auth_code'), + ]) + ->alias(CleanCommand::class, 'oauth_server.command.clean') + ; + + $services + ->set('oauth_server.command.create_client', CreateClientCommand::class) + ->args([ + service('oauth_server.doctrine_storage.client'), + ]) + ->alias(CreateClientCommand::class, 'oauth_server.command.create_client') + ; +}; diff --git a/src/Resources/config/doctrine_storage.php b/src/Resources/config/doctrine_storage.php new file mode 100644 index 0000000..d4be74c --- /dev/null +++ b/src/Resources/config/doctrine_storage.php @@ -0,0 +1,62 @@ +services(); + + $services + ->set('oauth_server.entity_manager', EntityManager::class) + ->args([ + 'default', + ]) + ->factory([service('doctrine'), 'getManager']) + ->private() + ; + + $services + ->set('oauth_server.doctrine_storage.access_token', AccessTokenStorage::class) + ->args([ + service('oauth_server.entity_manager'), + param('oauth_server.config.access_token_class'), + ]) + ->alias(AccessTokenStorage::class, 'oauth_server.doctrine_storage.access_token') + ; + + $services + ->set('oauth_server.doctrine_storage.auth_code', AuthCodeStorage::class) + ->args([ + service('oauth_server.entity_manager'), + param('oauth_server.config.auth_code_class'), + ]) + ->alias(AuthCodeStorage::class, 'oauth_server.doctrine_storage.auth_code') + ; + + $services + ->set('oauth_server.doctrine_storage.client', ClientStorage::class) + ->args([ + service('oauth_server.entity_manager'), + param('oauth_server.config.client_class'), + ]) + ->alias(ClientStorage::class, 'oauth_server.doctrine_storage.client') + ; + + $services + ->set('oauth_server.doctrine_storage.refresh_token', RefreshTokenStorage::class) + ->args([ + service('oauth_server.entity_manager'), + param('oauth_server.config.refresh_token_class'), + ]) + ->alias(RefreshTokenStorage::class, 'oauth_server.doctrine_storage.refresh_token') + ; +}; diff --git a/src/Resources/config/grant_extension.php b/src/Resources/config/grant_extension.php new file mode 100644 index 0000000..92f7c12 --- /dev/null +++ b/src/Resources/config/grant_extension.php @@ -0,0 +1,51 @@ +services(); + + $services + ->set('oauth_server.grant_extension.auth_code', AuthCodeGrantExtension::class) + ->args([ + service('oauth_server.doctrine_storage.auth_code'), + ]) + ->alias(AuthCodeGrantExtension::class, 'oauth_server.grant_extension.auth_code') + ; + + $services + ->set('oauth_server.grant_extension.client_credentials', ClientCredentialsGrantExtension::class) + ->alias(ClientCredentialsGrantExtension::class, 'oauth_server.grant_extension.client_credentials') + ; + + $services + ->set('oauth_server.grant_extension.refresh_token', RefreshTokenGrantExtension::class) + ->args([ + service('oauth_server.doctrine_storage.refresh_token'), + ]) + ->alias(RefreshTokenGrantExtension::class, 'oauth_server.grant_extension.refresh_token') + ; + + $services + ->set('oauth_server.grant_extension.user_credentials', UserCredentialsGrantExtension::class) + ->args([ + service('oauth_server.user_provider'), + service('security.password_hasher_factory'), + ]) + ->alias(UserCredentialsGrantExtension::class, 'oauth_server.grant_extension.user_credentials') + ; + + $services + ->set('oauth_server.grant_extension.custom', CustomGrantExtension::class) + ->alias(CustomGrantExtension::class, 'oauth_server.grant_extension.custom') + ; +}; diff --git a/src/Resources/config/handler.php b/src/Resources/config/handler.php new file mode 100644 index 0000000..3368589 --- /dev/null +++ b/src/Resources/config/handler.php @@ -0,0 +1,49 @@ +services(); + + $services + ->set('oauth_server.config', Config::class) + ->args([ + param('oauth_server.config.options'), + ]) + ->alias(Config::class, 'oauth_server.config') + ; + + $services + ->set('oauth_server.handler', Handler::class) + ->args([ + service('event_dispatcher'), + + service('oauth_server.bearer_token.chain_extractor'), + + service('oauth_server.token_generator.mt_rand'), + + service('oauth_server.config'), + + service('oauth_server.doctrine_storage.client'), + service('oauth_server.doctrine_storage.access_token'), + service('oauth_server.doctrine_storage.refresh_token'), + service('oauth_server.doctrine_storage.auth_code'), + + service('oauth_server.grant_extension.auth_code'), + service('oauth_server.grant_extension.client_credentials'), + service('oauth_server.grant_extension.refresh_token'), + service('oauth_server.grant_extension.user_credentials'), + service('oauth_server.grant_extension.custom'), + + service('oauth_server.config'), + ]) + ->alias(Handler::class, 'oauth_server.handler') + ; +}; diff --git a/src/Resources/config/security.php b/src/Resources/config/security.php new file mode 100644 index 0000000..896ac7a --- /dev/null +++ b/src/Resources/config/security.php @@ -0,0 +1,22 @@ +services(); + + $services + ->set('oauth_server.security.authenticator', OAuthAuthenticator::class) + ->args([ + service('security.user_checker'), + service('oauth_server.handler'), + service('oauth_server.config'), + ]) + ->alias(OAuthAuthenticator::class, 'oauth_server.security.authenticator') + ; +}; diff --git a/src/Resources/config/token_generator.php b/src/Resources/config/token_generator.php new file mode 100644 index 0000000..7c989b0 --- /dev/null +++ b/src/Resources/config/token_generator.php @@ -0,0 +1,15 @@ +services(); + + $services + ->set('oauth_server.token_generator.mt_rand', MtRandTokenGenerator::class) + ->alias(MtRandTokenGenerator::class, 'oauth_server.token_generator.mt_rand') + ; +}; diff --git a/src/Resources/routing/token.php b/src/Resources/routing/token.php new file mode 100644 index 0000000..ce7174e --- /dev/null +++ b/src/Resources/routing/token.php @@ -0,0 +1,15 @@ +add('oauth_server_token', '/oauth/v2/token') + ->controller([TokenController::class, 'token']) + ->methods([Request::METHOD_GET, Request::METHOD_HEAD]) + ; +}; diff --git a/src/Security/Authenticator/OAuthAuthenticator.php b/src/Security/Authenticator/OAuthAuthenticator.php new file mode 100644 index 0000000..763d785 --- /dev/null +++ b/src/Security/Authenticator/OAuthAuthenticator.php @@ -0,0 +1,105 @@ +handler->getBearerToken($request); + } + + public function authenticate(Request $request): Passport + { + try { + $tokenString = $this->handler->getBearerToken($request); + if (null === $tokenString) { + throw new AuthenticationException('OAuth2 authentication failed: missing access token.'); + } + + $accessToken = $this->handler->verifyAccessToken($tokenString); + + $user = $accessToken->getUser(); + + if (null !== $user) { + try { + $this->userChecker->checkPreAuth($user); + } catch (AccountStatusException $e) { + throw new OAuthAuthenticateException(Response::HTTP_UNAUTHORIZED, Handler::TOKEN_TYPE_BEARER, $this->config->getVariable(Config::CONFIG_WWW_REALM), 'access_denied', $e->getMessage()); + } + } + + $roles = (null !== $user) ? $user->getRoles() : []; + + $accessTokenBadge = new AccessTokenBadge($accessToken, $roles); + + return new SelfValidatingPassport(new UserBadge($user->getUserIdentifier()), [$accessTokenBadge]); + } catch (OAuthServerException $e) { + throw new AuthenticationException('OAuth2 authentication failed', 0, $e); + } + } + + public function createToken(Passport $passport, string $firewallName): TokenInterface + { + $badge = $passport->getBadge(AccessTokenBadge::class); + if (!$badge instanceof AccessTokenBadge) { + throw new \LogicException(''); + } + + $accessToken = $badge->getAccessToken(); + $token = new OAuthToken($badge->getRoles()); + $token->setToken($accessToken->getToken()); + if ($accessToken->getUser()) { + $token->setUser($accessToken->getUser()); + } + if (method_exists($token, 'setAuthenticated')) { + $token->setAuthenticated(true); + } + + return $token; + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response + { + $previousException = $exception->getPrevious(); + if ($previousException instanceof OAuthServerException) { + return $previousException->getHttpResponse(); + } + + return new JsonResponse([ + 'message' => strtr($exception->getMessageKey(), $exception->getMessageData()), + ], Response::HTTP_UNAUTHORIZED); + } +} diff --git a/src/Security/Authenticator/Passport/Badge/AccessTokenBadge.php b/src/Security/Authenticator/Passport/Badge/AccessTokenBadge.php new file mode 100644 index 0000000..b8af494 --- /dev/null +++ b/src/Security/Authenticator/Passport/Badge/AccessTokenBadge.php @@ -0,0 +1,32 @@ +roles); + } + + public function getAccessToken(): AccessTokenInterface + { + return $this->accessToken; + } + + public function getRoles(): array + { + return $this->roles; + } +} diff --git a/src/Security/Authenticator/Token/OAuthToken.php b/src/Security/Authenticator/Token/OAuthToken.php new file mode 100644 index 0000000..9cc801d --- /dev/null +++ b/src/Security/Authenticator/Token/OAuthToken.php @@ -0,0 +1,22 @@ +token = $token; + } + + public function getCredentials(): string + { + return $this->token; + } +} diff --git a/src/Security/EntryPoint/OAuthEntryPoint.php b/src/Security/EntryPoint/OAuthEntryPoint.php new file mode 100644 index 0000000..01bcfe6 --- /dev/null +++ b/src/Security/EntryPoint/OAuthEntryPoint.php @@ -0,0 +1,33 @@ +config->getVariable(Config::CONFIG_WWW_REALM), + 'access_denied', + 'OAuth2 authentication required' + ); + + return $exception->getHttpResponse(); + } +} diff --git a/src/Server/BearerToken/ChainExtractor.php b/src/Server/BearerToken/ChainExtractor.php new file mode 100644 index 0000000..2ef8db6 --- /dev/null +++ b/src/Server/BearerToken/ChainExtractor.php @@ -0,0 +1,34 @@ +extractors[] = $extractor; + } + + public function extract(Request $request, bool $removeFromRequest = false): ?string + { + foreach ($this->extractors as $extractor) { + $token = $extractor->extract($request, $removeFromRequest); + if (null !== $token) { + return $token; + } + } + + return null; + } +} diff --git a/src/Server/BearerToken/ExtractorInterface.php b/src/Server/BearerToken/ExtractorInterface.php new file mode 100644 index 0000000..e2506c0 --- /dev/null +++ b/src/Server/BearerToken/ExtractorInterface.php @@ -0,0 +1,12 @@ +server->has('CONTENT_TYPE')) { + return null; + } + + $contentType = $request->server->get('CONTENT_TYPE'); + + if (!preg_match('/^application\/x-www-form-urlencoded([\s|;].*)?$/', $contentType)) { + return null; + } + + if (Request::METHOD_GET === $request->getMethod()) { + return null; + } + + $body = $request->getContent(); + parse_str($body, $parameters); + + if (false === array_key_exists(self::FORM_NAME, $parameters)) { + return null; + } + + $token = $parameters[self::FORM_NAME]; + + if ($removeFromRequest && true === $request->request->has(self::FORM_NAME)) { + $request->request->remove(self::FORM_NAME); + } + + return $token; + } +} diff --git a/src/Server/BearerToken/HeaderExtractor.php b/src/Server/BearerToken/HeaderExtractor.php new file mode 100644 index 0000000..4637abf --- /dev/null +++ b/src/Server/BearerToken/HeaderExtractor.php @@ -0,0 +1,32 @@ +headers->get('AUTHORIZATION'); + if (!$header) { + return null; + } + + if (!preg_match('/' . preg_quote(self::HEADER_NAME, '/') . '\s(\S+)/', $header, $matches)) { + return null; + } + + $token = $matches[1]; + + if ($removeFromRequest) { + $request->headers->remove('AUTHORIZATION'); + } + + return $token; + } +} diff --git a/src/Server/BearerToken/QueryExtractor.php b/src/Server/BearerToken/QueryExtractor.php new file mode 100644 index 0000000..a910cfd --- /dev/null +++ b/src/Server/BearerToken/QueryExtractor.php @@ -0,0 +1,25 @@ +query->get(self::QUERY_NAME)) { + return null; + } + + if ($removeFromRequest) { + $request->query->remove(self::QUERY_NAME); + } + + return $token; + } +} diff --git a/src/Server/Config.php b/src/Server/Config.php new file mode 100644 index 0000000..0d5ed09 --- /dev/null +++ b/src/Server/Config.php @@ -0,0 +1,65 @@ +conf = [ + self::CONFIG_ACCESS_LIFETIME => self::DEFAULT_ACCESS_TOKEN_LIFETIME, + self::CONFIG_REFRESH_LIFETIME => self::DEFAULT_REFRESH_TOKEN_LIFETIME, + self::CONFIG_AUTH_LIFETIME => self::DEFAULT_AUTH_CODE_LIFETIME, + self::CONFIG_WWW_REALM => self::DEFAULT_WWW_REALM, + self::CONFIG_TOKEN_TYPE => self::DEFAULT_TOKEN_TYPE, + self::CONFIG_ENFORCE_INPUT_REDIRECT => self::DEFAULT_ENFORCE_INPUT_REDIRECT, + self::CONFIG_ENFORCE_STATE => self::DEFAULT_ENFORCE_STATE, + self::CONFIG_SUPPORTED_SCOPES => self::DEFAULT_SUPPORTED_SCOPES, + self::CONFIG_RESPONSE_EXTRA_HEADERS => self::DEFAULT_RESPONSE_EXTRA_HEADERS, + ]; + + foreach ($config as $name => $value) { + $this->setVariable($name, $value); + } + } + + public function getVariable(string $name, mixed $default = null): mixed + { + $name = strtolower($name); + + return $this->conf[$name] ?? $default; + } + + public function setVariable(string $name, mixed $value): self + { + $name = strtolower($name); + + $this->conf[$name] = $value; + + return $this; + } +} diff --git a/src/Server/GrantExtension/AuthCodeGrantExtension.php b/src/Server/GrantExtension/AuthCodeGrantExtension.php new file mode 100644 index 0000000..bc59c5a --- /dev/null +++ b/src/Server/GrantExtension/AuthCodeGrantExtension.php @@ -0,0 +1,49 @@ +getVariable(Config::CONFIG_ENFORCE_INPUT_REDIRECT)) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_REQUEST, 'The redirect URI parameter is required.'); + } + + $authCode = $this->storage->getAuthCode($input['code']); + + if (null === $authCode || $client->getPublicId() !== $authCode->getClient()->getPublicId()) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_GRANT, "Code doesn't exist or is invalid for the client"); + } + + if ($input['redirect_uri'] && RedirectUri::validate($input['redirect_uri'], [$authCode->getRedirectUri()])) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_REDIRECT_URI_MISMATCH, 'The redirect URI is missing or do not match'); + } + + if ($authCode->hasExpired()) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_GRANT, 'The authorization code has expired'); + } + + $this->storage->markAuthCodeAsUsed($authCode->getToken()); + + return new Grant($authCode->getUser(), $authCode->getScope()); + } +} diff --git a/src/Server/GrantExtension/ClientCredentialsGrantExtension.php b/src/Server/GrantExtension/ClientCredentialsGrantExtension.php new file mode 100644 index 0000000..97659a5 --- /dev/null +++ b/src/Server/GrantExtension/ClientCredentialsGrantExtension.php @@ -0,0 +1,30 @@ +checkSecret($clientCredentials[1])) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_GRANT); + } + + return new Grant(null, null, false); + } +} diff --git a/src/Server/GrantExtension/CustomGrantExtension.php b/src/Server/GrantExtension/CustomGrantExtension.php new file mode 100644 index 0000000..3fd85c6 --- /dev/null +++ b/src/Server/GrantExtension/CustomGrantExtension.php @@ -0,0 +1,53 @@ +extensions[$grantType] = $extension; + } + + public function getExtensions(): array + { + return $this->extensions; + } + + public function checkGrantExtension(ClientInterface $client, Config $config, string $grantType, array $input, array $headers): Grant + { + if (!str_starts_with($input['grant_type'], 'urn:') && !filter_var($input['grant_type'], FILTER_VALIDATE_URL)) { + throw new OAuthServerException( + Response::HTTP_BAD_REQUEST, + ErrorCode::ERROR_INVALID_REQUEST, + 'Invalid grant_type parameter or parameter missing' + ); + } + + $extension = $this->getExtensions()[$input['grant_type']] ?? null; + if (!$extension instanceof GrantExtensionInterface) { + throw new OAuthServerException( + Response::HTTP_BAD_REQUEST, + ErrorCode::ERROR_INVALID_REQUEST, + 'Invalid grant_type parameter or parameter missing' + ); + } + + return $extension->checkGrantExtension($client, $config, $grantType, $input, $headers); + } +} diff --git a/src/Server/GrantExtension/Grant.php b/src/Server/GrantExtension/Grant.php new file mode 100644 index 0000000..22104a9 --- /dev/null +++ b/src/Server/GrantExtension/Grant.php @@ -0,0 +1,32 @@ +user; + } + + public function getScope(): ?string + { + return $this->scope; + } + + public function issueRefreshToken(): bool + { + return $this->issueRefreshToken; + } +} diff --git a/src/Server/GrantExtension/GrantExtensionInterface.php b/src/Server/GrantExtension/GrantExtensionInterface.php new file mode 100644 index 0000000..877ce52 --- /dev/null +++ b/src/Server/GrantExtension/GrantExtensionInterface.php @@ -0,0 +1,17 @@ +storage->getRefreshToken($input['refresh_token']); + + if (null === $token || $client->getPublicId() !== $token->getClient()->getPublicId()) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_GRANT, 'Invalid refresh token'); + } + + if ($token->hasExpired()) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_GRANT, 'Refresh token has expired'); + } + + $this->storage->unsetRefreshToken($token->getToken()); + + return new Grant($token->getUser(), $token->getScope()); + } +} diff --git a/src/Server/GrantExtension/UserCredentialsGrantExtension.php b/src/Server/GrantExtension/UserCredentialsGrantExtension.php new file mode 100644 index 0000000..79a9698 --- /dev/null +++ b/src/Server/GrantExtension/UserCredentialsGrantExtension.php @@ -0,0 +1,55 @@ +userProvider->loadUserByIdentifier($input['username']); + } catch (AuthenticationException) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_GRANT, 'Invalid username and password combination'); + } + + if (!$user instanceof PasswordAuthenticatedUserInterface) { + throw new \LogicException('User must implement PasswordAuthenticatedUserInterface'); + } + + $encoder = $this->passwordHasherFactory->getPasswordHasher($user); + + if ($user instanceof LegacyPasswordAuthenticatedUserInterface && $encoder instanceof LegacyPasswordHasherInterface) { + if (!$encoder->verify($user->getPassword(), $input['password'], $user->getSalt())) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_GRANT, 'Invalid username and password combination'); + } + } elseif (!$encoder->verify($user->getPassword(), $input['password'])) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_GRANT, 'Invalid username and password combination'); + } + + return new Grant($user); + } +} diff --git a/src/Server/Handler.php b/src/Server/Handler.php new file mode 100644 index 0000000..134b849 --- /dev/null +++ b/src/Server/Handler.php @@ -0,0 +1,441 @@ +clientStorage = $clientStorage; + $this->accessTokenStorage = $accessTokenStorage; + $this->refreshTokenStorage = $refreshTokenStorage; + $this->authCodeStorage = $authCodeStorage; + + $this->authCodeGrantExtension = $authCodeGrantExtension; + $this->clientCredentialsGrantExtension = $clientCredentialsGrantExtension; + $this->refreshTokenGrantExtension = $refreshTokenGrantExtension; + $this->userCredentialsGrantExtension = $userCredentialsGrantExtension; + $this->customGrantExtension = $customGrantExtension; + } + + public function verifyAccessToken(string $tokenParam, ?string $scope = null): AccessTokenInterface + { + $tokenType = $this->config->getVariable(Config::CONFIG_TOKEN_TYPE); + $realm = $this->config->getVariable(Config::CONFIG_WWW_REALM); + + if (!$tokenParam) { + throw new OAuthAuthenticateException(Response::HTTP_BAD_REQUEST, $tokenType, $realm, ErrorCode::ERROR_INVALID_REQUEST, 'The request is missing a required parameter, includes an unsupported parameter or parameter value, repeats the same parameter, uses more than one method for including an access token, or is otherwise malformed.', $scope); + } + + $token = $this->accessTokenStorage->getAccessToken($tokenParam); + if (!$token) { + throw new OAuthAuthenticateException(Response::HTTP_UNAUTHORIZED, $tokenType, $realm, ErrorCode::ERROR_INVALID_GRANT, 'The access token provided is invalid.', $scope); + } + + if ($token->hasExpired()) { + throw new OAuthAuthenticateException(Response::HTTP_UNAUTHORIZED, $tokenType, $realm, ErrorCode::ERROR_INVALID_GRANT, 'The access token provided has expired.', $scope); + } + + if ($scope && (!$token->getScope() || !$this->checkScope($scope, $token->getScope()))) { + throw new OAuthAuthenticateException(Response::HTTP_FORBIDDEN, $tokenType, $realm, ErrorCode::ERROR_INSUFFICIENT_SCOPE, 'The request requires higher privileges than provided by the access token.', $scope); + } + + return $token; + } + + public function getBearerToken(Request $request, bool $removeFromRequest = false): ?string + { + return $this->bearerTokenExtractor->extract($request, $removeFromRequest); + } + + public function grantAccessToken(Request $request): Response + { + $filters = [ + 'grant_type' => [ + 'filter' => FILTER_VALIDATE_REGEXP, + 'options' => ['regexp' => self::REGEXP_GRANT_TYPE], + 'flags' => FILTER_REQUIRE_SCALAR, + ], + 'scope' => ['flags' => FILTER_REQUIRE_SCALAR], + 'client_id' => ['flags' => FILTER_REQUIRE_SCALAR], + 'client_secret' => ['flags' => FILTER_REQUIRE_SCALAR], + 'code' => ['flags' => FILTER_REQUIRE_SCALAR], + 'redirect_uri' => ['filter' => FILTER_SANITIZE_URL], + 'username' => ['flags' => FILTER_REQUIRE_SCALAR], + 'password' => ['flags' => FILTER_REQUIRE_SCALAR], + 'refresh_token' => ['flags' => FILTER_REQUIRE_SCALAR], + ]; + + if (Request::METHOD_POST === $request->getMethod()) { + $inputData = $request->request->all(); + } else { + $inputData = $request->query->all(); + } + + $authHeaders = [ + 'PHP_AUTH_USER' => $request->server->get('PHP_AUTH_USER'), + 'PHP_AUTH_PW' => $request->server->get('PHP_AUTH_PW'), + ]; + + $input = filter_var_array($inputData, $filters); + + if (!$input['grant_type']) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_REQUEST, 'Invalid grant_type parameter or parameter missing'); + } + + [$clientId, $clientSecret] = ClientCredentials::get($input, $authHeaders); + + $client = $this->clientStorage->getClient($clientId); + if (!$client) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_CLIENT, 'The client credentials are invalid'); + } + + if ($client->getSecret() && $client->getSecret() !== $clientSecret) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_CLIENT, 'The client credentials are invalid'); + } + + if (!$client->checkGrantType($input['grant_type'])) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_UNAUTHORIZED_CLIENT, 'The grant type is unauthorized for this client_id'); + } + + $grant = match ($input['grant_type']) { + self::GRANT_TYPE_AUTH_CODE => $this->authCodeGrantExtension->checkGrantExtension( + $client, + $this->config, + self::GRANT_TYPE_AUTH_CODE, + $input, + $authHeaders + ), + self::GRANT_TYPE_USER_CREDENTIALS => $this->userCredentialsGrantExtension->checkGrantExtension( + $client, + $this->config, + self::GRANT_TYPE_USER_CREDENTIALS, + $input, + $authHeaders + ), + self::GRANT_TYPE_CLIENT_CREDENTIALS => $this->clientCredentialsGrantExtension->checkGrantExtension( + $client, + $this->config, + self::GRANT_TYPE_CLIENT_CREDENTIALS, + $input, + $authHeaders + ), + self::GRANT_TYPE_REFRESH_TOKEN => $this->refreshTokenGrantExtension->checkGrantExtension( + $client, + $this->config, + self::GRANT_TYPE_REFRESH_TOKEN, + $input, + $authHeaders + ), + default => $this->customGrantExtension->checkGrantExtension( + $client, + $this->config, + $input['grant_type'], + $input, + $authHeaders + ), + }; + + $stored = [ + 'scope' => $this->config->getVariable(Config::CONFIG_SUPPORTED_SCOPES), + ]; + + $this->eventDispatcher->dispatch(new AfterGrantAccessEvent($input['grant_type'], $stored)); + + $scope = $grant->getScope(); + if ($input['scope']) { + if (!$grant->getScope() || !$this->checkScope($input['scope'], $scope)) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_SCOPE, 'An unsupported scope was requested.'); + } + + $scope = $input['scope']; + } + + $token = $this->createAccessToken( + $client, + $grant->getUser(), + $scope, + $this->config->getVariable(Config::CONFIG_ACCESS_LIFETIME), + $grant->issueRefreshToken(), + $this->config->getVariable(Config::CONFIG_REFRESH_LIFETIME), + ); + + $headers = $this->config->getVariable(Config::CONFIG_RESPONSE_EXTRA_HEADERS); + if (!$headers) { + $headers = []; + } + + $headers += [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-store', + 'Pragma' => 'no-cache', + ]; + + return new Response(json_encode($token, JSON_THROW_ON_ERROR), 200, $headers); + } + + public function finishClientAuthorization(bool $isAuthorized, Request $request, ?UserInterface $user = null, ?string $scope = null): Response + { + $filters = [ + 'client_id' => [ + 'filter' => FILTER_VALIDATE_REGEXP, + 'options' => ['regexp' => self::REGEXP_CLIENT_ID], + 'flags' => FILTER_REQUIRE_SCALAR, + ], + 'response_type' => ['flags' => FILTER_REQUIRE_SCALAR], + 'redirect_uri' => ['filter' => FILTER_SANITIZE_URL], + 'state' => ['flags' => FILTER_REQUIRE_SCALAR], + 'scope' => ['flags' => FILTER_REQUIRE_SCALAR], + ]; + + $inputData = $request->query->all(); + $input = filter_var_array($inputData, $filters); + + if (!$input['client_id']) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_REQUEST, 'No client id supplied'); + } + + $client = $this->clientStorage->getClient($input['client_id']); + if (!$client) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_INVALID_CLIENT, 'Unknown client'); + } + + $params = [ + 'client' => $client, + ]; + + if (empty($input['redirect_uri'])) { + if (!$client->getRedirectUris()) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_REDIRECT_URI_MISMATCH, 'No redirect URL was supplied or registered.'); + } + if (count($client->getRedirectUris()) > 1) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_REDIRECT_URI_MISMATCH, 'No redirect URL was supplied and more than one is registered.'); + } + if ($this->config->getVariable(Config::CONFIG_ENFORCE_INPUT_REDIRECT)) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_REDIRECT_URI_MISMATCH, 'The redirect URI is mandatory and was not supplied.'); + } + + $input['redirect_uri'] = $client->getRedirectUris()[0]; + } + + if (!RedirectUri::validate($input['redirect_uri'], $client->getRedirectUris())) { + throw new OAuthServerException(Response::HTTP_BAD_REQUEST, ErrorCode::ERROR_REDIRECT_URI_MISMATCH, 'The redirect URI provided does not match registered URI(s).'); + } + + if (!$input['response_type']) { + throw new OAuthRedirectException($input['redirect_uri'], ErrorCode::ERROR_INVALID_REQUEST, 'Invalid response type.', $input['state']); + } + + if ($input['scope'] && !$this->checkScope($input['scope'], $this->config->getVariable(Config::CONFIG_SUPPORTED_SCOPES))) { + throw new OAuthRedirectException($input['redirect_uri'], ErrorCode::ERROR_INVALID_SCOPE, 'An unsupported scope was requested.', $input['state']); + } + + if (!$input['state'] && $this->config->getVariable(Config::CONFIG_ENFORCE_STATE)) { + throw new OAuthRedirectException($input['redirect_uri'], ErrorCode::ERROR_INVALID_REQUEST, 'The state parameter is required.'); + } + + $params += $input; + $params += ['state' => null]; + + $result = []; + + if (false === $isAuthorized) { + $method = self::RESPONSE_TYPE_AUTH_CODE === $params['response_type'] ? TransportMethod::Query : TransportMethod::Fragment; + + throw new OAuthRedirectException( + $params['redirect_uri'], + ErrorCode::ERROR_USER_DENIED, + 'The user denied access to your application', + $params['state'], + $method + ); + } + + if (self::RESPONSE_TYPE_AUTH_CODE === $params['response_type']) { + $result[TransportMethod::Query->value]['state'] = $params['state']; + $result[TransportMethod::Query->value] += $this->createAuthCode( + $params['client'], + $user, + $params['redirect_uri'], + $scope + ); + } elseif (self::RESPONSE_TYPE_ACCESS_TOKEN === $params['response_type']) { + $result[TransportMethod::Fragment->value]['state'] = $params['state']; + $result[TransportMethod::Fragment->value] += $this->createAccessToken( + $params['client'], + $user, + $scope, + null, + false + ); + } else { + throw new OAuthServerException( + Response::HTTP_BAD_REQUEST, + ErrorCode::ERROR_UNSUPPORTED_RESPONSE_TYPE, + 'The response type is not supported by the authorization server.' + ); + } + + return new Response('', Response::HTTP_FOUND, [ + 'Location' => Uri::build($params['redirect_uri'], $result), + ]); + } + + /** + * @see http://tools.ietf.org/html/draft-ietf-oauth-v2-20#section-5 + */ + private function createAccessToken( + ClientInterface $client, + ?UserInterface $user, + ?string $scope = null, + ?int $accessTokenLifetime = null, + bool $issueRefreshToken = true, + ?int $refreshTokenLifetime = null + ): array { + if (null === $accessTokenLifetime) { + $accessTokenLifetime = (int) $this->config->getVariable(Config::CONFIG_ACCESS_LIFETIME); + } + + $token = [ + 'access_token' => $this->tokenGenerator->generate(), + 'expires_in' => $accessTokenLifetime, + 'token_type' => $this->config->getVariable(Config::CONFIG_TOKEN_TYPE), + 'scope' => $scope, + ]; + + $this->accessTokenStorage->createAccessToken( + $token['access_token'], + $client, + $user, + time() + $accessTokenLifetime, + $scope + ); + + if ($issueRefreshToken) { + $token['refresh_token'] = $this->tokenGenerator->generate(); + + if (null === $refreshTokenLifetime) { + $refreshTokenLifetime = (int) $this->config->getVariable(Config::CONFIG_REFRESH_LIFETIME); + } + + $this->refreshTokenStorage->createRefreshToken( + $token['refresh_token'], + $client, + $user, + time() + $refreshTokenLifetime, + $scope + ); + } + + return $token; + } + + private function createAuthCode(ClientInterface $client, ?UserInterface $user, string $redirectUri, ?string $scope = null): array + { + $token = [ + 'code' => $this->tokenGenerator->generate(), + ]; + + $this->authCodeStorage->createAuthCode( + $token['code'], + $client, + $user, + $redirectUri, + time() + (int) $this->config->getVariable(Config::CONFIG_AUTH_LIFETIME), + $scope + ); + + return $token; + } + + private function checkScope(?string $requiredScope, ?string $availableScope): bool + { + $requiredData = []; + if (null !== $requiredScope) { + $requiredData = explode(' ', trim($requiredScope)); + } + + $availableData = []; + if (null !== $availableScope) { + $availableData = explode(' ', trim($availableScope)); + } + + return 0 === count(array_diff($requiredData, $availableData)); + } +} diff --git a/src/Server/HandlerInterface.php b/src/Server/HandlerInterface.php new file mode 100644 index 0000000..d10b5a2 --- /dev/null +++ b/src/Server/HandlerInterface.php @@ -0,0 +1,37 @@ + $value) { + if (isset($parseUrl[$name])) { + $parseUrl[$name] .= '&' . http_build_query($value); + } else { + $parseUrl[$name] = http_build_query($value); + } + } + + return + ((isset($parseUrl['scheme'])) ? $parseUrl['scheme'] . '://' : '') + . ((isset($parseUrl['user'])) ? $parseUrl['user'] . ((isset($parseUrl['pass'])) ? ':' . $parseUrl['pass'] : '') . '@' : '') + . ($parseUrl['host'] ?? '') + . ((isset($parseUrl['port'])) ? ':' . $parseUrl['port'] : '') + . ($parseUrl['path'] ?? '') + . ((isset($parseUrl['query'])) ? '?' . $parseUrl['query'] : '') + . ((isset($parseUrl['fragment'])) ? '#' . $parseUrl['fragment'] : ''); + } +} diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php new file mode 100644 index 0000000..62d1b78 --- /dev/null +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,47 @@ +configuration = new Configuration(); + $this->processor = new Processor(); + } + + public function testConfiguration(): void + { + $config = $this->processor->processConfiguration($this->configuration, [[ + 'client_class' => Client::class, + 'access_token_class' => AccessToken::class, + 'refresh_token_class' => RefreshToken::class, + 'auth_code_class' => AuthCode::class, + 'user_provider' => 'in_memory_user_provider', + 'options' => [ + 'foo' => 'bar', + ], + ]]); + + $this->assertEquals([ + 'client_class' => Client::class, + 'access_token_class' => AccessToken::class, + 'refresh_token_class' => RefreshToken::class, + 'auth_code_class' => AuthCode::class, + 'user_provider' => 'in_memory_user_provider', + 'options' => [ + 'foo' => 'bar', + ], + ], $config); + } +} diff --git a/tests/DependencyInjection/OAuthServerExtensionTest.php b/tests/DependencyInjection/OAuthServerExtensionTest.php new file mode 100644 index 0000000..6fb92cb --- /dev/null +++ b/tests/DependencyInjection/OAuthServerExtensionTest.php @@ -0,0 +1,73 @@ +mockContainer(); + + $this->assertEquals(['foo' => 'bar'], $container->getParameter('oauth_server.config.options')); + $this->assertEquals(Client::class, $container->getParameter('oauth_server.config.client_class')); + $this->assertEquals(AccessToken::class, $container->getParameter('oauth_server.config.access_token_class')); + $this->assertEquals(RefreshToken::class, $container->getParameter('oauth_server.config.refresh_token_class')); + $this->assertEquals(AuthCode::class, $container->getParameter('oauth_server.config.auth_code_class')); + + $this->assertInstanceOf(Config::class, $container->get(Config::class)); + + $this->assertInstanceOf(FormExtractor::class, $container->get(FormExtractor::class)); + $this->assertInstanceOf(QueryExtractor::class, $container->get(QueryExtractor::class)); + $this->assertInstanceOf(HeaderExtractor::class, $container->get(HeaderExtractor::class)); + $this->assertInstanceOf(ChainExtractor::class, $container->get(ChainExtractor::class)); + $this->assertInstanceOf(MtRandTokenGenerator::class, $container->get(MtRandTokenGenerator::class)); + + $this->assertInstanceOf(AccessTokenStorage::class, $container->get(AccessTokenStorage::class)); + $this->assertInstanceOf(RefreshTokenStorage::class, $container->get(RefreshTokenStorage::class)); + $this->assertInstanceOf(AuthCodeStorage::class, $container->get(AuthCodeStorage::class)); + $this->assertInstanceOf(ClientStorage::class, $container->get(ClientStorage::class)); + + $this->assertInstanceOf(AuthCodeGrantExtension::class, $container->get(AuthCodeGrantExtension::class)); + $this->assertInstanceOf(ClientCredentialsGrantExtension::class, $container->get(ClientCredentialsGrantExtension::class)); + $this->assertInstanceOf(RefreshTokenGrantExtension::class, $container->get(RefreshTokenGrantExtension::class)); + $this->assertInstanceOf(UserCredentialsGrantExtension::class, $container->get(UserCredentialsGrantExtension::class)); + $this->assertInstanceOf(CustomGrantExtension::class, $container->get(CustomGrantExtension::class)); + + $this->assertInstanceOf(Handler::class, $container->get(Handler::class)); + + $this->assertInstanceOf(CleanCommand::class, $container->get(CleanCommand::class)); + $this->assertInstanceOf(CreateClientCommand::class, $container->get(CreateClientCommand::class)); + + $this->assertInstanceOf(OAuthAuthenticator::class, $container->get(OAuthAuthenticator::class)); + } +} diff --git a/tests/Server/HandlerTest.php b/tests/Server/HandlerTest.php new file mode 100644 index 0000000..6cbc9c6 --- /dev/null +++ b/tests/Server/HandlerTest.php @@ -0,0 +1,812 @@ +eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch()); + $bearerTokenExtractor = new ChainExtractor([ + new HeaderExtractor(), + new FormExtractor(), + new QueryExtractor(), + ]); + $this->tokenGenerator = $this->createMock(TokenGeneratorInterface::class); + + $this->clientStorage = $this->createMock(ClientStorageInterface::class); + $this->accessTokenStorage = $this->createMock(AccessTokenStorageInterface::class); + $this->refreshTokenStorage = $this->createMock(RefreshTokenStorageInterface::class); + $this->authCodeStorage = $this->createMock(AuthCodeStorageInterface::class); + + $this->userProviderInterface = new InMemoryUserProvider([ + 'test_user' => ['password' => '$2y$13$9OD4fb/aaUI1nvhstrDpi.JRLikEc3OeV4TNqu/j6.ICTFclKUws6'], + ]); + $this->passwordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + + $this->customGrantExtension = $this->getMockBuilder(CustomGrantExtension::class) + ->onlyMethods(['getExtensions']) + ->getMock() + ; + + $this->config = []; + + $this->manager = new Handler( + $this->eventDispatcher, + $bearerTokenExtractor, + $this->tokenGenerator, + new Config($this->config), + $this->clientStorage, + $this->accessTokenStorage, + $this->refreshTokenStorage, + $this->authCodeStorage, + new AuthCodeGrantExtension($this->authCodeStorage), + new ClientCredentialsGrantExtension(), + new RefreshTokenGrantExtension($this->refreshTokenStorage), + new UserCredentialsGrantExtension($this->userProviderInterface, $this->passwordHasherFactory), + $this->customGrantExtension, + ); + } + + public function testVerifyAccessToken(): void + { + $accessToken = new AccessToken(new Client('1')); + $accessToken + ->setToken('my_token') + ->setExpiresAt(time() + 10) + ->setScope('read') + ; + + $this + ->accessTokenStorage + ->method('getAccessToken') + ->willReturn($accessToken) + ; + + $token = $this->manager->verifyAccessToken('my_token'); + $this->assertNotNull($token); + $this->assertEquals('my_token', $token->getToken()); + } + + public static function provideVerifyAccessTokenException(): iterable + { + yield [ + null, + '', + null, + ]; + + $accessToken = new AccessToken(new Client('1')); + $accessToken + ->setToken('my_token') + ->setExpiresAt(0) + ->setScope('read') + ; + + yield [ + $accessToken, + 'my_token', + null, + ]; + + yield [ + null, + 'my_token', + null, + ]; + + $accessToken = new AccessToken(new Client('1')); + $accessToken + ->setToken('my_token') + ->setExpiresAt(time() + 10) + ; + + yield [ + $accessToken, + 'my_token', + 'read', + ]; + + $accessToken = new AccessToken(new Client('1')); + $accessToken + ->setToken('my_token') + ->setExpiresAt(time() + 10) + ->setScope('read') + ; + + yield [ + $accessToken, + 'my_token', + 'write', + ]; + } + + /** + * @dataProvider provideVerifyAccessTokenException + */ + public function testVerifyAccessTokenException( + ?AccessTokenInterface $token, + string $tokenParam, + ?string $scope = null + ): void { + $this->accessTokenStorage->method('getAccessToken')->willReturn($token); + + $this->expectException(OAuthAuthenticateException::class); + + $this->manager->verifyAccessToken($tokenParam, $scope); + } + + public static function provideGetBearerToken(): iterable + { + yield [ + new Request(), + false, + null, + ]; + + $request = new Request(); + $request->headers->set('AUTHORIZATION', 'Bearer foo'); + + yield [ + $request, + false, + 'foo', + ]; + + $request = new Request(); + $request->headers->set('AUTHORIZATION', 'Bearer foo'); + yield [ + $request, + true, + 'foo', + ]; + + yield [ + new Request(['access_token' => 'foo']), + false, + 'foo', + ]; + + yield [ + new Request(['access_token' => 'foo']), + true, + 'foo', + ]; + + yield [ + Request::create('/', 'GET', [], [], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded'], 'access_token=foo'), + false, + null, + ]; + + yield [ + Request::create('/', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded'], 'access_token=foo'), + false, + 'foo', + ]; + + $request = new Request(); + $request->setMethod('POST'); + $request->server->set('CONTENT_TYPE', 'multipart/form-data'); + $request->request->set('access_token', 'foo'); + yield [ + $request, + false, + null, + ]; + + yield [ + Request::create('/', 'POST', ['access_token' => 'foo'], [], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded'], 'access_token=foo'), + true, + 'foo', + ]; + + $request = new Request(); + $request->setMethod('POST'); + $request->headers->set('AUTHORIZATION', 'Bearer foo'); + $request->server->set('CONTENT_TYPE', 'application/x-www-form-urlencoded'); + yield [ + $request, + false, + 'foo', + ]; + + $request = new Request(['access_token' => 'foo']); + $request->headers->set('AUTHORIZATION', 'Basic Zm9vOmJhcg=='); + yield [ + $request, + false, + 'foo', + ]; + + $createRequest = static function ($method, $contentType) { + return Request::create('/', $method, [], [], [], ['CONTENT_TYPE' => $contentType], 'access_token=foo'); + }; + + foreach ([false, true] as $removeFromRequest) { + foreach (['POST', 'PUT', 'DELETE', 'FOOBAR'] as $method) { + yield [ + $createRequest($method, 'application/x-www-form-urlencoded'), + $removeFromRequest, + 'foo', + ]; + + yield [ + $createRequest($method, 'application/x-www-form-urlencoded; charset=utf-8'), + $removeFromRequest, + 'foo', + ]; + + yield [ + $createRequest($method, 'application/x-www-form-urlencoded mode=baz'), + $removeFromRequest, + 'foo', + ]; + + yield [ + $createRequest($method, 'application/x-www-form-urlencoded-foo'), + $removeFromRequest, + null, + ]; + } + } + } + + /** + * @dataProvider provideGetBearerToken + */ + public function testGetBearerToken(Request $request, bool $removeFromRequest, ?string $expectedToken): void + { + $token = $this->manager->getBearerToken($request, $removeFromRequest); + + $this->assertEquals($expectedToken, $token); + } + + public static function provideGrantAccessTokenAuthCode(): iterable + { + yield [ + new Request( + [ + 'grant_type' => 'authorization_code', + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'code' => 'foo', + 'redirect_uri' => 'http://google.ru', + ] + ), + [ + 'access_token' => 'access_token', + 'expires_in' => 3600, + 'token_type' => 'bearer', + 'scope' => 'one two three', + 'refresh_token' => 'refresh_token', + ], + ]; + + yield [ + new Request( + [ + 'grant_type' => 'authorization_code', + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'code' => 'foo', + 'redirect_uri' => 'http://google.ru', + 'scope' => 'two three', + ] + ), + [ + 'access_token' => 'access_token', + 'expires_in' => 3600, + 'token_type' => 'bearer', + 'scope' => 'two three', + 'refresh_token' => 'refresh_token', + ], + ]; + } + + /** + * @dataProvider provideGrantAccessTokenAuthCode + */ + public function testGrantAccessTokenAuthCode(Request $request, array $expectedResponse): void + { + $client = new Client('public_id'); + $client + ->setSecret('bar') + ->setRedirectUris([]) + ->setGrantTypes(['authorization_code']) + ; + + $this->clientStorage + ->method('getClient') + ->willReturn($client) + ; + + $authCode = new AuthCode($client); + $authCode + ->setToken('auth_token') + ->setExpiresAt(time() + 20) + ->setScope('one two three') + ; + + $this->authCodeStorage + ->method('getAuthCode') + ->willReturn($authCode) + ; + + $this->tokenGenerator + ->method('generate') + ->willReturn('access_token', 'refresh_token') + ; + + $response = $this->manager->grantAccessToken($request); + + $this->assertEquals($expectedResponse, json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR)); + $this->assertEquals([AfterGrantAccessEvent::class], $this->eventDispatcher->getOrphanedEvents()); + } + + public static function provideGrantAccessTokenUserCredentials(): iterable + { + yield [ + new Request( + [ + 'grant_type' => 'password', + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'username' => 'test_user', + 'password' => 'test_password', + ] + ), + ]; + } + + /** + * @dataProvider provideGrantAccessTokenUserCredentials + */ + public function testGrantAccessTokenUserCredentials(Request $request): void + { + $client = new Client('public_id'); + $client + ->setSecret('bar') + ->setGrantTypes(['authorization_code', 'password']) + ; + + $this->clientStorage + ->method('getClient') + ->willReturn($client) + ; + + $this->passwordHasherFactory + ->method('getPasswordHasher') + ->willReturn(new NativePasswordHasher()) + ; + + $this->tokenGenerator + ->method('generate') + ->willReturn('access_token', 'refresh_token') + ; + + $response = $this->manager->grantAccessToken($request); + + $this->assertEquals([ + 'access_token' => 'access_token', + 'expires_in' => 3600, + 'token_type' => 'bearer', + 'scope' => null, + 'refresh_token' => 'refresh_token', + ], json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR)); + $this->assertEquals([AfterGrantAccessEvent::class], $this->eventDispatcher->getOrphanedEvents()); + } + + public static function provideGrantAccessTokenClientCredentials(): iterable + { + yield [ + new Request( + [ + 'grant_type' => 'client_credentials', + 'client_id' => 'foo', + 'client_secret' => 'bar', + ] + ), + ]; + } + + /** + * @dataProvider provideGrantAccessTokenClientCredentials + */ + public function testGrantAccessTokenClientCredentials(Request $request): void + { + $client = new Client('public_id'); + $client + ->setSecret('bar') + ->setGrantTypes(['client_credentials']) + ; + + $this->clientStorage + ->method('getClient') + ->willReturn($client) + ; + + $this->tokenGenerator + ->method('generate') + ->willReturn('access_token', 'refresh_token') + ; + + $response = $this->manager->grantAccessToken($request); + + $this->assertEquals([ + 'access_token' => 'access_token', + 'expires_in' => 3600, + 'token_type' => 'bearer', + 'scope' => null, + ], json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR)); + $this->assertEquals([AfterGrantAccessEvent::class], $this->eventDispatcher->getOrphanedEvents()); + } + + public static function provideGrantAccessTokenRefreshToken(): iterable + { + yield [ + new Request( + [ + 'grant_type' => 'refresh_token', + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'refresh_token' => 'test_refresh_token', + ] + ), + ]; + } + + /** + * @dataProvider provideGrantAccessTokenRefreshToken + */ + public function testGrantAccessTokenRefreshToken(Request $request): void + { + $client = new Client('public_id'); + $client + ->setSecret('bar') + ->setGrantTypes(['refresh_token']) + ; + + $this->clientStorage + ->method('getClient') + ->willReturn($client) + ; + + $refreshToken = new RefreshToken($client); + $refreshToken + ->setToken('test_refresh_token') + ->setExpiresAt(time() + 20) + ->setScope('read') + ; + + $this->refreshTokenStorage + ->method('getRefreshToken') + ->willReturn($refreshToken) + ; + + $this->tokenGenerator + ->method('generate') + ->willReturn('access_token', 'refresh_token') + ; + + $response = $this->manager->grantAccessToken($request); + + $this->assertEquals([ + 'access_token' => 'access_token', + 'expires_in' => 3600, + 'token_type' => 'bearer', + 'scope' => 'read', + 'refresh_token' => 'refresh_token', + ], json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR)); + $this->assertEquals([AfterGrantAccessEvent::class], $this->eventDispatcher->getOrphanedEvents()); + } + + public static function provideGrantAccessTokenCustom(): iterable + { + yield [ + new Request( + [ + 'grant_type' => 'urn:custom', + 'client_id' => 'foo', + 'client_secret' => 'bar', + ] + ), + ]; + } + + /** + * @dataProvider provideGrantAccessTokenCustom + */ + public function testGrantAccessTokenCustom(Request $request): void + { + $client = new Client('public_id'); + $client + ->setSecret('bar') + ->setGrantTypes(['urn:custom']) + ; + + $this->clientStorage + ->method('getClient') + ->willReturn($client) + ; + + $custom = new class() implements GrantExtensionInterface { + public function checkGrantExtension(ClientInterface $client, Config $config, string $grantType, array $input, array $headers): Grant + { + return new Grant(null, null); + } + }; + + $this->customGrantExtension + ->method('getExtensions') + ->willReturn(['urn:custom' => $custom]) + ; + + $this->tokenGenerator + ->method('generate') + ->willReturn('access_token', 'refresh_token') + ; + + $response = $this->manager->grantAccessToken($request); + + $this->assertEquals([ + 'access_token' => 'access_token', + 'expires_in' => 3600, + 'token_type' => 'bearer', + 'scope' => null, + 'refresh_token' => 'refresh_token', + ], json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR)); + $this->assertEquals([AfterGrantAccessEvent::class], $this->eventDispatcher->getOrphanedEvents()); + } + + public static function provideGrantAccessTokenException(): iterable + { + yield [ + new Request(), + 'invalid_request', + ]; + + yield [ + new Request(server: ['grant_type' => 'authorization_code']), + 'invalid_request', + ]; + + yield [ + new Request(['grant_type' => 'authorization_code']), + 'invalid_client', + ]; + + yield [ + new Request([ + 'grant_type' => 'authorization_code', + 'client_id' => 'foo', + ]), + 'invalid_client', + ]; + + yield [ + new Request([ + 'grant_type' => 'authorization_code', + 'client_id' => 'public_id', + ]), + 'invalid_client', + ]; + + yield [ + new Request([ + 'grant_type' => 'authorization_code', + 'client_id' => 'public_id', + 'client_secret' => 'foo', + ]), + 'invalid_client', + ]; + + yield [ + new Request([ + 'grant_type' => 'authorization_code', + 'client_id' => 'public_id', + 'client_secret' => 'bar', + ]), + 'unauthorized_client', + ]; + } + + /** + * @dataProvider provideGrantAccessTokenException + */ + public function testGrantAccessTokenException(Request $request, string $expectedMessage): void + { + $this->expectException(OAuthServerException::class); + $this->expectExceptionMessage($expectedMessage); + + if ('public_id' === $request->get('client_id')) { + $client = new Client('public_id'); + $client->setSecret('bar'); + + $this->clientStorage + ->method('getClient') + ->willReturn($client) + ; + } + + $this->manager->grantAccessToken($request); + } + + public static function provideFinishClientAuthorization(): iterable + { + yield [ + true, + new Request([ + 'grant_type' => 'authorization_code', + 'client_id' => 'public_id', + 'redirect_uri' => 'https://google.com', + 'response_type' => 'code', + ]), + null, + null, + 'https://google.com?code=foo_token', + ]; + + yield [ + true, + new Request([ + 'grant_type' => 'authorization_code', + 'client_id' => 'public_id', + 'redirect_uri' => 'https://google.com', + 'response_type' => 'token', + ]), + null, + null, + 'https://google.com#access_token=foo_token&expires_in=3600&token_type=bearer', + ]; + } + + /** + * @dataProvider provideFinishClientAuthorization + */ + public function testFinishClientAuthorization( + bool $isAuthorized, + Request $request, + ?array $data, + ?string $scope, + string $expectedLocation, + ): void { + $client = new Client('public_id'); + $client + ->setSecret('bar') + ->setRedirectUris(['https://google.com']) + ; + + $this->clientStorage + ->method('getClient') + ->willReturn($client) + ; + + $this->tokenGenerator + ->method('generate') + ->willReturn('foo_token') + ; + + $response = $this->manager->finishClientAuthorization($isAuthorized, $request, $data, $scope); + + $this->assertEquals(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertEquals($expectedLocation, $response->headers->get('Location')); + } + + public static function provideFinishClientAuthorizationException(): iterable + { + yield [ + new Request([ + 'grant_type' => 'authorization_code', + ]), + 'invalid_request', + ]; + + yield [ + new Request([ + 'grant_type' => 'authorization_code', + 'client_id' => 'foo', + ]), + 'invalid_client', + ]; + + yield [ + new Request([ + 'grant_type' => 'authorization_code', + 'client_id' => 'public_id', + ]), + 'redirect_uri_mismatch', + ]; + + yield [ + new Request([ + 'grant_type' => 'authorization_code', + 'client_id' => 'public_id', + 'redirect_uri' => 'http://foo.com', + ]), + 'redirect_uri_mismatch', + ]; + + yield [ + new Request([ + 'grant_type' => 'authorization_code', + 'client_id' => 'public_id', + 'redirect_uri' => 'http://bar.com', + ]), + 'invalid_request', + ]; + + yield [ + new Request([ + 'grant_type' => 'authorization_code', + 'client_id' => 'public_id', + 'redirect_uri' => 'http://bar.com', + 'response_type' => 'foo', + ]), + 'unsupported_response_type', + ]; + } + + /** + * @dataProvider provideFinishClientAuthorizationException + */ + public function testFinishClientAuthorizationException(Request $request, string $expectedMessage): void + { + $this->expectException(OAuthServerException::class); + $this->expectExceptionMessage($expectedMessage); + + if ('public_id' === $request->get('client_id')) { + $client = new Client('public_id'); + $client + ->setSecret('bar') + ->setRedirectUris(['http://bar.com']) + ; + + $this->clientStorage + ->method('getClient') + ->willReturn($client) + ; + } + + $this->manager->finishClientAuthorization(true, $request); + } +} diff --git a/tests/Stub/ContainerTrait.php b/tests/Stub/ContainerTrait.php new file mode 100644 index 0000000..365280b --- /dev/null +++ b/tests/Stub/ContainerTrait.php @@ -0,0 +1,84 @@ +load([[ + 'client_class' => Client::class, + 'access_token_class' => AccessToken::class, + 'refresh_token_class' => RefreshToken::class, + 'auth_code_class' => AuthCode::class, + 'user_provider' => 'in_memory_user_provider', + 'options' => [ + 'foo' => 'bar', + ], + ]], $container); + + $entityProviderMock = $this + ->createMock(EntityManagerProvider::class) + ; + + $entityManagerMock = $this + ->createMock(EntityManagerInterface::class) + ; + + $entityProviderMock + ->method('getManager') + ->with(self::anything()) + ->willReturn($entityManagerMock) + ; + + $entityManagerMock + ->method('getRepository') + ->willReturnCallback(function ($className) { + return match ($className) { + AccessToken::class => new AccessTokenRepositoryStub(), + RefreshToken::class => new RefreshTokenRepositoryStub(), + AuthCode::class => new AuthCodeRepositoryStub(), + Client::class => new ClientRepositoryStub(), + default => throw new \InvalidArgumentException('Unknown repository class'), + }; + }) + ; + + $container->set('doctrine', $entityProviderMock); + $container->set('event_dispatcher', new EventDispatcher()); + $container->set('security.user_checker', new InMemoryUserChecker()); + $container->set('in_memory_user_provider', new InMemoryUserProvider()); + $container->set('security.password_hasher_factory', new PasswordHasherFactory([])); + + return $container; + } +} diff --git a/tests/Stub/Entity/AccessToken.php b/tests/Stub/Entity/AccessToken.php new file mode 100644 index 0000000..d720378 --- /dev/null +++ b/tests/Stub/Entity/AccessToken.php @@ -0,0 +1,82 @@ +user = $user; + + return $this; + } + + public function getUser(): ?UserInterface + { + return $this->user; + } + + public function setClient(ClientInterface $client): self + { + $this->client = $client; + + return $this; + } + + public function getClient(): ClientInterface + { + return $this->client; + } + + public function setExpiresAt(?int $expiresAt): self + { + $this->expiresAt = $expiresAt; + + return $this; + } + + public function getExpiresAt(): ?int + { + return $this->expiresAt; + } + + public function setToken(string $token): self + { + $this->token = $token; + + return $this; + } + + public function getToken(): string + { + return $this->token; + } + + public function setScope(?string $scope): self + { + $this->scope = $scope; + + return $this; + } + + public function getScope(): ?string + { + return $this->scope; + } +} diff --git a/tests/Stub/Entity/AuthCode.php b/tests/Stub/Entity/AuthCode.php new file mode 100644 index 0000000..ca74182 --- /dev/null +++ b/tests/Stub/Entity/AuthCode.php @@ -0,0 +1,95 @@ +user = $user; + + return $this; + } + + public function getUser(): ?UserInterface + { + return $this->user; + } + + public function setClient(ClientInterface $client): self + { + $this->client = $client; + + return $this; + } + + public function getClient(): ClientInterface + { + return $this->client; + } + + public function setExpiresAt(?int $expiresAt): self + { + $this->expiresAt = $expiresAt; + + return $this; + } + + public function getExpiresAt(): ?int + { + return $this->expiresAt; + } + + public function setToken(string $token): self + { + $this->token = $token; + + return $this; + } + + public function getToken(): string + { + return $this->token; + } + + public function setScope(?string $scope): self + { + $this->scope = $scope; + + return $this; + } + + public function getScope(): ?string + { + return $this->scope; + } + + public function setRedirectUri(string $redirectUri): self + { + $this->redirectUri = $redirectUri; + + return $this; + } + + public function getRedirectUri(): string + { + return $this->redirectUri; + } +} diff --git a/tests/Stub/Entity/Client.php b/tests/Stub/Entity/Client.php new file mode 100644 index 0000000..287140c --- /dev/null +++ b/tests/Stub/Entity/Client.php @@ -0,0 +1,75 @@ +randomId = $publicId; + } + + public function getPublicId(): string + { + return $this->randomId; + } + + public function setSecret(?string $secret): self + { + $this->secret = $secret; + + return $this; + } + + public function getSecret(): ?string + { + return $this->secret; + } + + public function setRedirectUris(array $redirectUris): self + { + $this->redirectUris = $redirectUris; + + return $this; + } + + public function getRedirectUris(): array + { + return $this->redirectUris; + } + + public function setGrantTypes(array $grantTypes): self + { + $this->allowedGrantTypes = $grantTypes; + + return $this; + } + + public function getGrantTypes(): array + { + return $this->allowedGrantTypes; + } + + public function getRoles(): array + { + return []; + } + + public function eraseCredentials(): void + { + } + + public function getUserIdentifier(): string + { + return $this->randomId; + } +} diff --git a/tests/Stub/Entity/RefreshToken.php b/tests/Stub/Entity/RefreshToken.php new file mode 100644 index 0000000..cc15966 --- /dev/null +++ b/tests/Stub/Entity/RefreshToken.php @@ -0,0 +1,82 @@ +user = $user; + + return $this; + } + + public function getUser(): ?UserInterface + { + return $this->user; + } + + public function setClient(ClientInterface $client): self + { + $this->client = $client; + + return $this; + } + + public function getClient(): ClientInterface + { + return $this->client; + } + + public function setExpiresAt(?int $expiresAt): self + { + $this->expiresAt = $expiresAt; + + return $this; + } + + public function getExpiresAt(): ?int + { + return $this->expiresAt; + } + + public function setToken(string $token): self + { + $this->token = $token; + + return $this; + } + + public function getToken(): string + { + return $this->token; + } + + public function setScope(?string $scope): self + { + $this->scope = $scope; + + return $this; + } + + public function getScope(): ?string + { + return $this->scope; + } +} diff --git a/tests/Stub/Repository/AccessTokenRepositoryStub.php b/tests/Stub/Repository/AccessTokenRepositoryStub.php new file mode 100644 index 0000000..58bf36a --- /dev/null +++ b/tests/Stub/Repository/AccessTokenRepositoryStub.php @@ -0,0 +1,39 @@ + */ + private array $tokens = []; + + public function findByToken(string $token): ?AccessTokenInterface + { + return $this->tokens[$token] ?? null; + } + + public function createAccessToken(ClientInterface $client): AccessTokenInterface + { + return new AccessToken( + $client, + 'bar' + ); + } + + public function updateAccessToken(AccessTokenInterface $token): void + { + $this->tokens[$token->getToken()] = $token; + } + + public function deleteTokenExpired(): int + { + return 0; + } +} diff --git a/tests/Stub/Repository/AuthCodeRepositoryStub.php b/tests/Stub/Repository/AuthCodeRepositoryStub.php new file mode 100644 index 0000000..1f95e0e --- /dev/null +++ b/tests/Stub/Repository/AuthCodeRepositoryStub.php @@ -0,0 +1,41 @@ + */ + private array $codes = []; + + public function findByCode(string $code): ?AuthCodeInterface + { + return $this->codes[$code] ?? null; + } + + public function createAuthCode(ClientInterface $client): AuthCodeInterface + { + return new AuthCode($client); + } + + public function updateAuthCode(AuthCodeInterface $authCode): void + { + $this->codes[$authCode->getToken()] = $authCode; + } + + public function deleteAuthCode(AuthCodeInterface $authCode): void + { + unset($this->codes[$authCode->getToken()]); + } + + public function deleteTokenExpired(): int + { + return 0; + } +} diff --git a/tests/Stub/Repository/ClientRepositoryStub.php b/tests/Stub/Repository/ClientRepositoryStub.php new file mode 100644 index 0000000..6d07122 --- /dev/null +++ b/tests/Stub/Repository/ClientRepositoryStub.php @@ -0,0 +1,36 @@ + */ + private array $tokens = []; + + public function createClient(): ClientInterface + { + $client = new Client(Random::generateToken()); + $client->setSecret(Random::generateToken()); + + $this->tokens[$client->getPublicId()] = $client; + + return $client; + } + + public function updateClient(ClientInterface $client): void + { + $this->tokens[$client->getPublicId()] = $client; + } + + public function findByPublicId(string $publicId): ?ClientInterface + { + return $this->tokens[$publicId] ?? null; + } +} diff --git a/tests/Stub/Repository/RefreshTokenRepositoryStub.php b/tests/Stub/Repository/RefreshTokenRepositoryStub.php new file mode 100644 index 0000000..0765303 --- /dev/null +++ b/tests/Stub/Repository/RefreshTokenRepositoryStub.php @@ -0,0 +1,41 @@ + */ + private array $tokens = []; + + public function findByToken(string $token): ?RefreshTokenInterface + { + return $this->tokens[$token] ?? null; + } + + public function createRefreshToke(ClientInterface $client): RefreshTokenInterface + { + return new RefreshToken($client); + } + + public function updateRefreshToke(RefreshTokenInterface $token): void + { + $this->tokens[$token->getToken()] = $token; + } + + public function deleteRefreshToke(RefreshTokenInterface $token): void + { + unset($this->tokens[$token->getToken()]); + } + + public function deleteTokenExpired(): int + { + return 0; + } +}