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;
+ }
+}