diff --git a/docs/index.md b/docs/index.md index 6b293992..83838e94 100644 --- a/docs/index.md +++ b/docs/index.md @@ -157,6 +157,7 @@ security: * [Using custom client](using-custom-client.md) * [Listening to League OAuth Server events](listening-to-league-events.md) * [Password Grant Handling](password-grant-handling.md) +* [Using custom persistence managers](using-custom-persistence-managers.md) ## Contributing diff --git a/docs/using-custom-persistence-managers.md b/docs/using-custom-persistence-managers.md new file mode 100644 index 00000000..d53292aa --- /dev/null +++ b/docs/using-custom-persistence-managers.md @@ -0,0 +1,110 @@ +# Using custom persistence managers + +Implement the 4 interfaces from the `League\Bundle\OAuth2ServerBundle\Manager` namespace: +- [AccessTokenManagerInterface](../src/Manager/AccessTokenManagerInterface.php) +- [AuthorizationCodeManagerInterface](../src/Manager/AuthorizationCodeManagerInterface.php) +- [ClientManagerInterface](../src/Manager/ClientManagerInterface.php) +- [RefreshTokenManagerInterface](../src/Manager/RefreshTokenManagerInterface.php) +And the interface for `CredentialsRevokerInterface`: +- [CredentialsRevokerInterface](../src/Service/CredentialsRevokerInterface.php) + +```php + +Example: + +```php +class MyAccessTokenManager implements AccessTokenManagerInterface +{ +} + +class MyAuthorizationCodeManager implements AuthorizationCodeManagerInterface +{ +} + +class MyClientManager implements ClientManagerInterface +{ +} + +class MyRefreshTokenManager implements RefreshTokenManagerInterface +{ +} + +class MyCredentialsRevoker implements CredentialsRevokerInterface +{ +} +``` + +Then register the services in the container: + +```yaml +services: + _defaults: + autoconfigure: true + + App\Manager\MyAccessTokenManager: ~ + App\Manager\MyAuthorizationCodeManager: ~ + App\Manager\MyClientManager: ~ + App\Manager\MyRefreshTokenManager: ~ + App\Service\MyCredentialsRevoker: ~ +``` + +Finally, configure the bundle to use the new managers: + +```yaml +league_oauth2_server: + persistence: + custom: + access_token_manager: App\Manager\MyAccessTokenManager + authorization_code_manager: App\Manager\MyAuthorizationCodeManager + client_manager: App\Manager\MyClientManager + refresh_token_manager: App\Manager\MyRefreshTokenManager + credentials_revoker: App\Service\MyCredentialsRevoker +``` + +## Optional + +Example MySql table schema for custom persistence managers implementation: +```sql +CREATE TABLE `oauth2_access_token` ( + `identifier` char(80) NOT NULL, + `client` varchar(32) NOT NULL, + `expiry` datetime NOT NULL, + `userIdentifier` varchar(128) DEFAULT NULL, + `scopes` text, + `revoked` tinyint(1) NOT NULL, + PRIMARY KEY (`identifier`), + KEY `client` (`client`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `oauth2_authorization_code` ( + `identifier` char(80) NOT NULL, + `client` varchar(32) NOT NULL, + `expiry` datetime NOT NULL, + `userIdentifier` varchar(128) DEFAULT NULL, + `scopes` text, + `revoked` tinyint(1) NOT NULL, + PRIMARY KEY (`identifier`), + KEY `client` (`client`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `oauth2_client` ( + `identifier` varchar(32) NOT NULL, + `name` varchar(128) NOT NULL, + `secret` varchar(128) DEFAULT NULL, + `redirectUris` text, + `grants` text, + `scopes` text, + `active` tinyint(1) NOT NULL, + `allowPlainTextPkce` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`identifier`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `oauth2_refresh_token` ( + `identifier` char(80) NOT NULL, + `access_token` char(80) DEFAULT NULL, + `expiry` datetime NOT NULL, + `revoked` tinyint(1) NOT NULL, + PRIMARY KEY (`identifier`), + KEY `access_token` (`access_token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 932ac44e..3ab21af2 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -195,6 +195,36 @@ private function createPersistenceNode(): NodeDefinition // In-memory persistence ->scalarNode('in_memory') ->end() + // Custom persistence + ->arrayNode('custom') + ->children() + ->scalarNode('access_token_manager') + ->info('Service id of the custom access token manager') + ->cannotBeEmpty() + ->isRequired() + ->end() + ->scalarNode('authorization_code_manager') + ->info('Service id of the custom authorization code manager') + ->cannotBeEmpty() + ->isRequired() + ->end() + ->scalarNode('client_manager') + ->info('Service id of the custom client manager') + ->cannotBeEmpty() + ->isRequired() + ->end() + ->scalarNode('refresh_token_manager') + ->info('Service id of the custom refresh token manager') + ->cannotBeEmpty() + ->isRequired() + ->end() + ->scalarNode('credentials_revoker') + ->info('Service id of the custom credentials revoker') + ->cannotBeEmpty() + ->isRequired() + ->end() + ->end() + ->end() ->end() ; diff --git a/src/DependencyInjection/LeagueOAuth2ServerExtension.php b/src/DependencyInjection/LeagueOAuth2ServerExtension.php index 3c551d0d..780c3e1b 100644 --- a/src/DependencyInjection/LeagueOAuth2ServerExtension.php +++ b/src/DependencyInjection/LeagueOAuth2ServerExtension.php @@ -10,15 +10,20 @@ use League\Bundle\OAuth2ServerBundle\DBAL\Type\Grant as GrantType; use League\Bundle\OAuth2ServerBundle\DBAL\Type\RedirectUri as RedirectUriType; use League\Bundle\OAuth2ServerBundle\DBAL\Type\Scope as ScopeType; +use League\Bundle\OAuth2ServerBundle\Manager\AccessTokenManagerInterface; +use League\Bundle\OAuth2ServerBundle\Manager\AuthorizationCodeManagerInterface; +use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface; use League\Bundle\OAuth2ServerBundle\Manager\Doctrine\AccessTokenManager; use League\Bundle\OAuth2ServerBundle\Manager\Doctrine\AuthorizationCodeManager; use League\Bundle\OAuth2ServerBundle\Manager\Doctrine\ClientManager; use League\Bundle\OAuth2ServerBundle\Manager\Doctrine\RefreshTokenManager; use League\Bundle\OAuth2ServerBundle\Manager\InMemory\AccessTokenManager as InMemoryAccessTokenManager; +use League\Bundle\OAuth2ServerBundle\Manager\RefreshTokenManagerInterface; use League\Bundle\OAuth2ServerBundle\Manager\ScopeManagerInterface; use League\Bundle\OAuth2ServerBundle\Persistence\Mapping\Driver; use League\Bundle\OAuth2ServerBundle\Security\Authenticator\OAuth2Authenticator; use League\Bundle\OAuth2ServerBundle\Service\CredentialsRevoker\DoctrineCredentialsRevoker; +use League\Bundle\OAuth2ServerBundle\Service\CredentialsRevokerInterface; use League\Bundle\OAuth2ServerBundle\ValueObject\Scope as ScopeModel; use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\CryptKey; @@ -104,10 +109,13 @@ public function process(ContainerBuilder $container) private function assertRequiredBundlesAreEnabled(ContainerBuilder $container): void { $requiredBundles = [ - 'doctrine' => DoctrineBundle::class, 'security' => SecurityBundle::class, ]; + if ($container->hasParameter('league.oauth2_server.persistence.doctrine.enabled')) { + $requiredBundles['doctrine'] = DoctrineBundle::class; + } + foreach ($requiredBundles as $bundleAlias => $requiredBundle) { if (!$container->hasExtension($bundleAlias)) { throw new \LogicException(sprintf('Bundle \'%s\' needs to be enabled in your application kernel.', $requiredBundle)); @@ -233,6 +241,9 @@ private function configurePersistence(LoaderInterface $loader, ContainerBuilder $loader->load('storage/doctrine.php'); $this->configureDoctrinePersistence($container, $config, $persistenceConfig); break; + case 'custom': + $this->configureCustomPersistence($container, $persistenceConfig); + break; } } @@ -291,6 +302,17 @@ private function configureInMemoryPersistence(ContainerBuilder $container, array $container->setParameter('league.oauth2_server.persistence.in_memory.enabled', true); } + private function configureCustomPersistence(ContainerBuilder $container, array $persistenceConfig): void + { + $container->setAlias(ClientManagerInterface::class, $persistenceConfig['client_manager']); + $container->setAlias(AccessTokenManagerInterface::class, $persistenceConfig['access_token_manager']); + $container->setAlias(RefreshTokenManagerInterface::class, $persistenceConfig['refresh_token_manager']); + $container->setAlias(AuthorizationCodeManagerInterface::class, $persistenceConfig['authorization_code_manager']); + $container->setAlias(CredentialsRevokerInterface::class, $persistenceConfig['credentials_revoker']); + + $container->setParameter('league.oauth2_server.persistence.custom.enabled', true); + } + private function configureResourceServer(ContainerBuilder $container, array $config): void { $container diff --git a/tests/Acceptance/CustomPersistenceManagerTest.php b/tests/Acceptance/CustomPersistenceManagerTest.php new file mode 100644 index 00000000..6ff66eb6 --- /dev/null +++ b/tests/Acceptance/CustomPersistenceManagerTest.php @@ -0,0 +1,173 @@ +client = self::createClient(); + $this->accessTokenManager = $this->createMock(AccessTokenManagerInterface::class); + $this->clientManager = $this->createMock(ClientManagerInterface::class); + $this->refreshTokenManager = $this->createMock(RefreshTokenManagerInterface::class); + $this->authCodeManager = $this->createMock(AuthorizationCodeManagerInterface::class); + $this->application = new Application($this->client->getKernel()); + } + + public function testRegisteredServices(): void + { + static::assertInstanceOf(FakeAccessTokenManager::class, $this->client->getContainer()->get(AccessTokenManagerInterface::class)); + static::assertInstanceOf(FakeAuthorizationCodeManager::class, $this->client->getContainer()->get(AuthorizationCodeManagerInterface::class)); + static::assertInstanceOf(FakeClientManager::class, $this->client->getContainer()->get(ClientManagerInterface::class)); + static::assertInstanceOf(FakeRefreshTokenManager::class, $this->client->getContainer()->get(RefreshTokenManagerInterface::class)); + static::assertInstanceOf(FakeCredentialsRevoker::class, $this->client->getContainer()->get(CredentialsRevokerInterface::class)); + } + + public function testSuccessfulClientCredentialsRequest(): void + { + $this->accessTokenManager->expects(self::atLeastOnce())->method('find')->willReturn(null); + $this->accessTokenManager->expects(self::atLeastOnce())->method('save'); + $this->client->getContainer()->set('test.access_token_manager', $this->accessTokenManager); + + $this->clientManager->expects(self::atLeastOnce())->method('find')->with('foo')->willReturn(new Client('name', 'foo', 'secret')); + $this->client->getContainer()->set('test.client_manager', $this->clientManager); + + $this->client->request('POST', '/token', [ + 'client_id' => 'foo', + 'client_secret' => 'secret', + 'grant_type' => 'client_credentials', + ]); + + $this->client->getResponse(); + static::assertResponseIsSuccessful(); + } + + public function testSuccessfulPasswordRequest(): void + { + $this->accessTokenManager->expects(self::atLeastOnce())->method('find')->willReturn(null); + $this->accessTokenManager->expects(self::atLeastOnce())->method('save'); + $this->client->getContainer()->set('test.access_token_manager', $this->accessTokenManager); + + $this->clientManager->expects(self::atLeastOnce())->method('find')->with('foo')->willReturn(new Client('name', 'foo', 'secret')); + $this->client->getContainer()->set('test.client_manager', $this->clientManager); + + $eventDispatcher = $this->client->getContainer()->get('event_dispatcher'); + $eventDispatcher->addListener(OAuth2Events::USER_RESOLVE, static function (UserResolveEvent $event): void { + $event->setUser(FixtureFactory::createUser()); + }); + + $this->client->request('POST', '/token', [ + 'client_id' => 'foo', + 'client_secret' => 'secret', + 'grant_type' => 'password', + 'username' => 'user', + 'password' => 'pass', + ]); + + $this->client->getResponse(); + static::assertResponseIsSuccessful(); + } + + public function testSuccessfulRefreshTokenRequest(): void + { + $client = new Client('name', 'foo', 'secret'); + $accessToken = new AccessToken('access_token', new \DateTimeImmutable('+1 hour'), $client, 'user', []); + $refreshToken = new RefreshToken('refresh_token', new \DateTimeImmutable('+1 month'), $accessToken); + + $this->refreshTokenManager->expects(self::atLeastOnce())->method('find')->willReturn($refreshToken, null); + $this->client->getContainer()->set('test.refresh_token_manager', $this->refreshTokenManager); + + $this->accessTokenManager->expects(self::atLeastOnce())->method('find')->willReturn($accessToken, null); + $this->accessTokenManager->expects(self::atLeastOnce())->method('save'); + $this->client->getContainer()->set('test.access_token_manager', $this->accessTokenManager); + + $this->clientManager->expects(self::atLeastOnce())->method('find')->with('foo')->willReturn($client); + $this->client->getContainer()->set('test.client_manager', $this->clientManager); + + $this->client->request('POST', '/token', [ + 'client_id' => 'foo', + 'client_secret' => 'secret', + 'grant_type' => 'refresh_token', + 'refresh_token' => TestHelper::generateEncryptedPayload($refreshToken), + ]); + + $this->client->getResponse(); + static::assertResponseIsSuccessful(); + } + + public function testSuccessfulAuthorizationCodeRequest(): void + { + $client = new Client('name', 'foo', 'secret'); + $client->setRedirectUris(new RedirectUri('https://example.org/oauth2/redirect-uri')); + $authCode = new AuthorizationCode('authorization_code', new \DateTimeImmutable('+2 minute'), $client, 'user', []); + + $this->authCodeManager->expects(self::atLeastOnce())->method('find')->willReturn($authCode, null); + $this->client->getContainer()->set('test.authorization_code_manager', $this->authCodeManager); + + $this->accessTokenManager->expects(self::atLeastOnce())->method('find')->willReturn(null); + $this->accessTokenManager->expects(self::atLeastOnce())->method('save'); + $this->client->getContainer()->set('test.access_token_manager', $this->accessTokenManager); + + $this->clientManager->expects(self::atLeastOnce())->method('find')->with('foo')->willReturn($client); + $this->client->getContainer()->set('test.client_manager', $this->clientManager); + + $this->client->request('POST', '/token', [ + 'client_id' => 'foo', + 'client_secret' => 'secret', + 'grant_type' => 'authorization_code', + 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', + 'code' => TestHelper::generateEncryptedAuthCodePayload($authCode), + ]); + + $this->client->getResponse(); + static::assertResponseIsSuccessful(); + } + + protected static function createKernel(array $options = []): KernelInterface + { + return new TestKernel( + 'test', + false, + [ + 'custom' => [ + 'access_token_manager' => 'test.access_token_manager', + 'authorization_code_manager' => 'test.authorization_code_manager', + 'client_manager' => 'test.client_manager', + 'refresh_token_manager' => 'test.refresh_token_manager', + 'credentials_revoker' => 'test.credentials_revoker', + ], + ] + ); + } +} diff --git a/tests/Fixtures/FakeAccessTokenManager.php b/tests/Fixtures/FakeAccessTokenManager.php new file mode 100644 index 00000000..47bbc793 --- /dev/null +++ b/tests/Fixtures/FakeAccessTokenManager.php @@ -0,0 +1,25 @@ + ['entity_manager' => 'default']]) + { + parent::__construct($environment, $debug); + } + public function boot(): void { $this->initializeEnvironmentVariables(); @@ -155,15 +165,12 @@ public function registerContainerConfiguration(LoaderInterface $loader): void FixtureFactory::FIXTURE_SCOPE_SECOND, ], ], - 'persistence' => [ - 'doctrine' => [ - 'entity_manager' => 'default', - ], - ], + 'persistence' => $this->persistenceConfig, ]); $this->configureControllers($container); $this->configureDatabaseServices($container); + $this->configureCustomPersistenceServices($container); $this->registerFakeGrant($container); }); } @@ -214,6 +221,15 @@ private function configureDatabaseServices(ContainerBuilder $container): void ; } + private function configureCustomPersistenceServices(ContainerBuilder $container): void + { + $container->register('test.access_token_manager', FakeAccessTokenManager::class)->setPublic(true); + $container->register('test.authorization_code_manager', FakeAuthorizationCodeManager::class)->setPublic(true); + $container->register('test.client_manager', FakeClientManager::class)->setPublic(true); + $container->register('test.refresh_token_manager', FakeRefreshTokenManager::class)->setPublic(true); + $container->register('test.credentials_revoker', FakeCredentialsRevoker::class)->setPublic(true); + } + private function registerFakeGrant(ContainerBuilder $container): void { $container->register(FakeGrant::class)->setAutoconfigured(true);