From db82e08e3353ffd0d26d5ad6e0b118fc6c7d5374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Wed, 16 Jan 2019 15:06:37 +0100 Subject: [PATCH 01/44] Add support for the "authorization_code" grant type --- Controller/AuthorizationController.php | 74 ++++++++++++ DependencyInjection/Configuration.php | 5 + .../TrikoderOAuth2Extension.php | 18 +++ League/Entity/AuthCode.php | 13 +++ League/Repository/AuthCodeRepository.php | 109 ++++++++++++++++++ Manager/AuthCodeManagerInterface.php | 12 ++ Manager/Doctrine/AuthCodeManager.php | 37 ++++++ Manager/InMemory/AuthCodeManager.php | 24 ++++ Model/AuthCode.php | 97 ++++++++++++++++ OAuth2Grants.php | 3 +- .../config/doctrine/model/AuthCode.orm.xml | 19 +++ Resources/config/routes.xml | 1 + Resources/config/services.xml | 21 ++++ Resources/config/storage/doctrine.xml | 4 + Resources/config/storage/in_memory.xml | 2 + .../Voter/AlwaysApproveAuthRequestVoter.php | 20 ++++ Tests/Acceptance/AbstractAcceptanceTest.php | 4 +- .../Acceptance/AuthorizationEndpointTest.php | 68 +++++++++++ Tests/Acceptance/TokenEndpointTest.php | 32 +++++ Tests/Fixtures/FixtureFactory.php | 36 +++++- Tests/Fixtures/User.php | 2 +- Tests/Integration/AbstractIntegrationTest.php | 11 +- Tests/TestHelper.php | 21 ++++ Tests/TestKernel.php | 20 ++++ 24 files changed, 647 insertions(+), 6 deletions(-) create mode 100644 Controller/AuthorizationController.php create mode 100644 League/Entity/AuthCode.php create mode 100644 League/Repository/AuthCodeRepository.php create mode 100644 Manager/AuthCodeManagerInterface.php create mode 100644 Manager/Doctrine/AuthCodeManager.php create mode 100644 Manager/InMemory/AuthCodeManager.php create mode 100644 Model/AuthCode.php create mode 100644 Resources/config/doctrine/model/AuthCode.orm.xml create mode 100644 Security/Authorization/Voter/AlwaysApproveAuthRequestVoter.php create mode 100644 Tests/Acceptance/AuthorizationEndpointTest.php diff --git a/Controller/AuthorizationController.php b/Controller/AuthorizationController.php new file mode 100644 index 00000000..bbcf1a8a --- /dev/null +++ b/Controller/AuthorizationController.php @@ -0,0 +1,74 @@ +server = $server; + $this->authorizationChecker = $authorizationChecker; + $this->tokenStorage = $tokenStorage; + } + + public function indexAction(ServerRequestInterface $serverRequest): ResponseInterface + { + if (!$this->authorizationChecker->isGranted('IS_AUTHENTICATED_REMEMBERED')) { + throw new LogicException('There is no logged in user. Review your security config to protect this endpoint.'); + } + + $serverResponse = new Response(); + + try { + $authRequest = $this->server->validateAuthorizationRequest($serverRequest); + $authRequest->setUser($this->getUserEntity()); + $authRequest->setAuthorizationApproved($this->authorizationChecker->isGranted($authRequest)); + + return $this->server->completeAuthorizationRequest($authRequest, $serverResponse); + } catch (OAuthServerException $e) { + return $e->generateHttpResponse($serverResponse); + } + } + + private function getUserEntity(): User + { + $token = $this->tokenStorage->getToken(); + if (null === $token) { + throw new LogicException('There is no security token available. Review your security config to protect endpoint.'); + } + + $user = $token->getUser(); + $username = $user instanceof UserInterface ? $user->getUsername() : (string) $user; + + $userEntity = new User(); + $userEntity->setIdentifier($username); + + return $userEntity; + } +} diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index ca1b6a1a..db931f29 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -53,6 +53,11 @@ private function createAuthorizationServerNode(): NodeDefinition ->cannotBeEmpty() ->defaultValue('P1M') ->end() + ->scalarNode('auth_code_ttl') + ->info("How long the issued auth code should be valid for.\nThe value should be a valid interval: http://php.net/manual/en/dateinterval.construct.php#refsect1-dateinterval.construct-parameters") + ->cannotBeEmpty() + ->defaultValue('PT10M') + ->end() ->end() ; diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 771472cb..28ec8502 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -89,6 +89,11 @@ private function configureAuthorizationServer(ContainerBuilder $container, array new Definition(DateInterval::class, [$config['access_token_ttl']]), ]); + $authorizationServer->addMethodCall('enableGrantType', [ + new Reference('league.oauth2.server.grant.auth_code_grant'), + new Definition(DateInterval::class, [$config['access_token_ttl']]), + ]); + $this->configureGrants($container, $config); } @@ -107,6 +112,14 @@ private function configureGrants(ContainerBuilder $container, array $config): vo new Definition(DateInterval::class, [$config['refresh_token_ttl']]), ]) ; + + $container + ->getDefinition('league.oauth2.server.grant.auth_code_grant') + ->replaceArgument('$authCodeTTL', new Definition(DateInterval::class, [$config['auth_code_ttl']])) + ->addMethodCall('setRefreshTokenTTL', [ + new Definition(DateInterval::class, [$config['refresh_token_ttl']]), + ]) + ; } private function configurePersistence(LoaderInterface $loader, ContainerBuilder $container, array $config) @@ -153,6 +166,11 @@ private function configureDoctrinePersistence(ContainerBuilder $container, array ->replaceArgument('$entityManager', $entityManager) ; + $container + ->getDefinition('trikoder.oauth2.manager.doctrine.auth_code_manager') + ->replaceArgument('$entityManager', $entityManager) + ; + $container->setParameter('trikoder.oauth2.persistence.doctrine.enabled', true); $container->setParameter('trikoder.oauth2.persistence.doctrine.manager', $entityManagerName); } diff --git a/League/Entity/AuthCode.php b/League/Entity/AuthCode.php new file mode 100644 index 00000000..d9aecf2c --- /dev/null +++ b/League/Entity/AuthCode.php @@ -0,0 +1,13 @@ +authCodeManager = $authCodeManager; + $this->clientManager = $clientManager; + $this->scopeConverter = $scopeConverter; + } + + /** + * {@inheritdoc} + */ + public function getNewAuthCode() + { + return new AuthCodeEntity(); + } + + /** + * {@inheritdoc} + */ + public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity) + { + $authCode = $this->authCodeManager->find($authCodeEntity->getIdentifier()); + + if (null !== $authCode) { + throw UniqueTokenIdentifierConstraintViolationException::create(); + } + + $authCode = $this->buildAuthCodeModel($authCodeEntity); + + $this->authCodeManager->save($authCode); + } + + /** + * {@inheritdoc} + */ + public function revokeAuthCode($codeId) + { + $authCode = $this->authCodeManager->find($codeId); + + if (null === $codeId) { + return; + } + + $authCode->revoke(); + + $this->authCodeManager->save($authCode); + } + + /** + * {@inheritdoc} + */ + public function isAuthCodeRevoked($codeId) + { + $authCode = $this->authCodeManager->find($codeId); + + if (null === $authCode) { + return true; + } + + return $authCode->isRevoked(); + } + + private function buildAuthCodeModel(AuthCodeEntity $authCodeEntity): AuthCodeModel + { + $client = $this->clientManager->find($authCodeEntity->getClient()->getIdentifier()); + + $authCode = new AuthCodeModel( + $authCodeEntity->getIdentifier(), + $authCodeEntity->getExpiryDateTime(), + $client, + $authCodeEntity->getUserIdentifier(), + $this->scopeConverter->toDomainArray($authCodeEntity->getScopes()) + ); + + return $authCode; + } +} diff --git a/Manager/AuthCodeManagerInterface.php b/Manager/AuthCodeManagerInterface.php new file mode 100644 index 00000000..810c63d2 --- /dev/null +++ b/Manager/AuthCodeManagerInterface.php @@ -0,0 +1,12 @@ +entityManager = $entityManager; + } + + /** + * {@inheritdoc} + */ + public function find(string $identifier): ?AuthCode + { + return $this->entityManager->find(AuthCode::class, $identifier); + } + + /** + * {@inheritdoc} + */ + public function save(AuthCode $authCode): void + { + $this->entityManager->persist($authCode); + $this->entityManager->flush(); + } +} diff --git a/Manager/InMemory/AuthCodeManager.php b/Manager/InMemory/AuthCodeManager.php new file mode 100644 index 00000000..1b3e412b --- /dev/null +++ b/Manager/InMemory/AuthCodeManager.php @@ -0,0 +1,24 @@ +authCodes[$identifier] ?? null; + } + + public function save(AuthCode $authCode): void + { + $this->authCodes[$authCode->getIdentifier()] = $authCode; + } +} diff --git a/Model/AuthCode.php b/Model/AuthCode.php new file mode 100644 index 00000000..3d3ac7b8 --- /dev/null +++ b/Model/AuthCode.php @@ -0,0 +1,97 @@ +identifier = $identifier; + $this->expiry = $expiry; + $this->client = $client; + $this->userIdentifier = $userIdentifier; + $this->scopes = $scopes; + } + + public function __toString(): string + { + return $this->getIdentifier(); + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + public function getExpiryDateTime(): DateTime + { + return $this->expiry; + } + + public function getUserIdentifier(): ?string + { + return $this->userIdentifier; + } + + public function getClient(): Client + { + return $this->client; + } + + /** + * @return Scope[] + */ + public function getScopes(): array + { + return $this->scopes; + } + + public function isRevoked(): bool + { + return $this->revoked; + } + + public function revoke(): self + { + $this->revoked = true; + + return $this; + } +} diff --git a/OAuth2Grants.php b/OAuth2Grants.php index 1f408a71..98167dab 100644 --- a/OAuth2Grants.php +++ b/OAuth2Grants.php @@ -41,11 +41,12 @@ final class OAuth2Grants public static function has(string $grant): bool { - // TODO: Add support for "authorization_code" and "implicit" grant types. + // TODO: Add support for "implicit" grant type. return \in_array($grant, [ self::CLIENT_CREDENTIALS, self::PASSWORD, self::REFRESH_TOKEN, + self::AUTHORIZATION_CODE, ]); } } diff --git a/Resources/config/doctrine/model/AuthCode.orm.xml b/Resources/config/doctrine/model/AuthCode.orm.xml new file mode 100644 index 00000000..c2d19457 --- /dev/null +++ b/Resources/config/doctrine/model/AuthCode.orm.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/Resources/config/routes.xml b/Resources/config/routes.xml index 07a7f883..fc7df6c8 100644 --- a/Resources/config/routes.xml +++ b/Resources/config/routes.xml @@ -3,5 +3,6 @@ xsi:schemaLocation="http://symfony.com/schema/routing http://symfony.com/schema/routing/routing-1.0.xsd"> + diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 8e6bb027..34df0f60 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -29,6 +29,11 @@ + + + + + @@ -41,6 +46,9 @@ + + + @@ -66,6 +74,19 @@ + + + + + + + + + + + + + diff --git a/Resources/config/storage/doctrine.xml b/Resources/config/storage/doctrine.xml index 780a23fe..f9e39b7a 100644 --- a/Resources/config/storage/doctrine.xml +++ b/Resources/config/storage/doctrine.xml @@ -7,6 +7,7 @@ + @@ -19,5 +20,8 @@ + + + diff --git a/Resources/config/storage/in_memory.xml b/Resources/config/storage/in_memory.xml index 9b9f4199..2e7564a6 100644 --- a/Resources/config/storage/in_memory.xml +++ b/Resources/config/storage/in_memory.xml @@ -7,11 +7,13 @@ + + diff --git a/Security/Authorization/Voter/AlwaysApproveAuthRequestVoter.php b/Security/Authorization/Voter/AlwaysApproveAuthRequestVoter.php new file mode 100644 index 00000000..8104de9c --- /dev/null +++ b/Security/Authorization/Voter/AlwaysApproveAuthRequestVoter.php @@ -0,0 +1,20 @@ +client->getContainer()->get(ScopeManagerInterface::class), $this->client->getContainer()->get(ClientManagerInterface::class), $this->client->getContainer()->get(AccessTokenManagerInterface::class), - $this->client->getContainer()->get(RefreshTokenManagerInterface::class) + $this->client->getContainer()->get(RefreshTokenManagerInterface::class), + $this->client->getContainer()->get(AuthCodeManagerInterface::class) ); } } diff --git a/Tests/Acceptance/AuthorizationEndpointTest.php b/Tests/Acceptance/AuthorizationEndpointTest.php new file mode 100644 index 00000000..8bfaae41 --- /dev/null +++ b/Tests/Acceptance/AuthorizationEndpointTest.php @@ -0,0 +1,68 @@ +client->request( + 'GET', + '/authorize', + [ + 'client_id' => FixtureFactory::FIXTURE_CLIENT_FIRST, + 'response_type' => 'code', + 'state' => 'foobar', + ], + [], + [ + 'PHP_AUTH_USER' => FixtureFactory::FIXTURE_USER, + 'PHP_AUTH_PW' => FixtureFactory::FIXTURE_PASSWORD, + ] + ); + + timecop_return(); + + $response = $this->client->getResponse(); + + $this->assertSame(302, $response->getStatusCode()); + $redirectUri = $response->headers->get('Location'); + + $this->assertStringStartsWith(FixtureFactory::FIXTURE_CLIENT_FIRST_REDIRECT_URI, $redirectUri); + $query = []; + parse_str(parse_url($redirectUri, PHP_URL_QUERY), $query); + $this->assertArrayHasKey('code', $query); + $this->assertArrayHasKey('state', $query); + $this->assertEquals('foobar', $query['state']); + } + + public function testFailedAuthorizeRequest() + { + $this->client->request( + 'GET', + '/authorize', + [], + [], + [ + 'PHP_AUTH_USER' => FixtureFactory::FIXTURE_USER, + 'PHP_AUTH_PW' => FixtureFactory::FIXTURE_PASSWORD, + ] + ); + + $response = $this->client->getResponse(); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame('application/json', $response->headers->get('Content-Type')); + + $jsonResponse = json_decode($response->getContent(), true); + + $this->assertSame('unsupported_grant_type', $jsonResponse['error']); + $this->assertSame('The authorization grant type is not supported by the authorization server.', $jsonResponse['message']); + $this->assertSame('Check that all required parameters have been provided', $jsonResponse['hint']); + } +} diff --git a/Tests/Acceptance/TokenEndpointTest.php b/Tests/Acceptance/TokenEndpointTest.php index d272c5c6..48aa86d5 100644 --- a/Tests/Acceptance/TokenEndpointTest.php +++ b/Tests/Acceptance/TokenEndpointTest.php @@ -4,6 +4,7 @@ use DateTime; use Trikoder\Bundle\OAuth2Bundle\Event\UserResolveEvent; +use Trikoder\Bundle\OAuth2Bundle\Manager\AuthCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Tests\Fixtures\FixtureFactory; use Trikoder\Bundle\OAuth2Bundle\Tests\TestHelper; @@ -99,6 +100,37 @@ public function testSuccessfulRefreshTokenRequest() $this->assertNotEmpty($jsonResponse['refresh_token']); } + public function testSuccessfulAuthorizationCodeRequest() + { + $authCode = $this->client + ->getContainer() + ->get(AuthCodeManagerInterface::class) + ->find(FixtureFactory::FIXTURE_AUTH_CODE); + + timecop_freeze(new DateTime()); + + $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), + ]); + + timecop_return(); + + $response = $this->client->getResponse(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json; charset=UTF-8', $response->headers->get('Content-Type')); + + $jsonResponse = json_decode($response->getContent(), true); + + $this->assertSame('Bearer', $jsonResponse['token_type']); + $this->assertSame(3600, $jsonResponse['expires_in']); + $this->assertNotEmpty($jsonResponse['access_token']); + } + public function testFailedTokenRequest() { $this->client->request('GET', '/token'); diff --git a/Tests/Fixtures/FixtureFactory.php b/Tests/Fixtures/FixtureFactory.php index dc4d4416..7dd71c95 100644 --- a/Tests/Fixtures/FixtureFactory.php +++ b/Tests/Fixtures/FixtureFactory.php @@ -4,12 +4,15 @@ use DateTime; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Manager\AuthCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken; +use Trikoder\Bundle\OAuth2Bundle\Model\AuthCode; use Trikoder\Bundle\OAuth2Bundle\Model\Client; use Trikoder\Bundle\OAuth2Bundle\Model\Grant; +use Trikoder\Bundle\OAuth2Bundle\Model\RedirectUri; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken; use Trikoder\Bundle\OAuth2Bundle\Model\Scope; @@ -28,21 +31,27 @@ final class FixtureFactory public const FIXTURE_REFRESH_TOKEN_EXPIRED = '3b3db453a137debb7b5f445c971bef18bb4f045d272a66a27054a0713096d2a8377679d204495c88'; public const FIXTURE_REFRESH_TOKEN_REVOKED = '63641841630c2e4d747e0f9ebe12ee04424e322874b8e68ef69fd58f1899ef70beb09733e23928a6'; + public const FIXTURE_AUTH_CODE = '0aa70e8152259988b3c8e9e8cff604019bb986eb226bd126da189829b95a2be631e2506042064e12'; + public const FIXTURE_CLIENT_FIRST = 'foo'; public const FIXTURE_CLIENT_SECOND = 'bar'; public const FIXTURE_CLIENT_INACTIVE = 'baz_inactive'; public const FIXTURE_CLIENT_RESTRCITED_GRANTS = 'qux_restricted'; + public const FIXTURE_CLIENT_FIRST_REDIRECT_URI = 'https://example.org/oauth2/redirect-uri'; + public const FIXTURE_SCOPE_FIRST = 'fancy'; public const FIXTURE_SCOPE_SECOND = 'rock'; public const FIXTURE_USER = 'user'; + public const FIXTURE_PASSWORD = 'pass'; public static function initializeFixtures( ScopeManagerInterface $scopeManager, ClientManagerInterface $clientManager, AccessTokenManagerInterface $accessTokenManager, - RefreshTokenManagerInterface $refreshTokenManager + RefreshTokenManagerInterface $refreshTokenManager, + AuthCodeManagerInterface $authCodeManager ): void { foreach (self::createScopes() as $scope) { $scopeManager->save($scope); @@ -59,6 +68,10 @@ public static function initializeFixtures( foreach (self::createRefreshTokens($accessTokenManager) as $refreshToken) { $refreshTokenManager->save($refreshToken); } + + foreach (self::createAuthCodes($clientManager) as $authCode) { + $authCodeManager->save($authCode); + } } /** @@ -163,6 +176,24 @@ public static function createRefreshTokens(AccessTokenManagerInterface $accessTo return $refreshTokens; } + /** + * @return AuthCode[] + */ + public static function createAuthCodes(ClientManagerInterface $clientManager): array + { + $authCodes = []; + + $authCodes[] = new AuthCode( + self::FIXTURE_AUTH_CODE, + new DateTime('+2 minute'), + $clientManager->find(self::FIXTURE_CLIENT_FIRST), + self::FIXTURE_USER, + [] + ); + + return $authCodes; + } + /** * @return Client[] */ @@ -170,7 +201,8 @@ public static function createClients(): array { $clients = []; - $clients[] = new Client(self::FIXTURE_CLIENT_FIRST, 'secret'); + $clients[] = (new Client(self::FIXTURE_CLIENT_FIRST, 'secret')) + ->setRedirectUris(new RedirectUri(self::FIXTURE_CLIENT_FIRST_REDIRECT_URI)); $clients[] = new Client(self::FIXTURE_CLIENT_SECOND, 'top_secret'); diff --git a/Tests/Fixtures/User.php b/Tests/Fixtures/User.php index c79f2131..43c31749 100644 --- a/Tests/Fixtures/User.php +++ b/Tests/Fixtures/User.php @@ -20,7 +20,7 @@ public function getRoles() */ public function getPassword() { - return null; + return FixtureFactory::FIXTURE_PASSWORD; } /** diff --git a/Tests/Integration/AbstractIntegrationTest.php b/Tests/Integration/AbstractIntegrationTest.php index d936eae3..4e779951 100644 --- a/Tests/Integration/AbstractIntegrationTest.php +++ b/Tests/Integration/AbstractIntegrationTest.php @@ -27,8 +27,10 @@ use Trikoder\Bundle\OAuth2Bundle\League\Repository\ScopeRepository; use Trikoder\Bundle\OAuth2Bundle\League\Repository\UserRepository; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Manager\AuthCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\AccessTokenManager; +use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\AuthCodeManager; use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\ClientManager; use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\RefreshTokenManager; use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\ScopeManager; @@ -58,6 +60,11 @@ abstract class AbstractIntegrationTest extends TestCase */ protected $accessTokenManager; + /** + * @var AuthCodeManagerInterface + */ + protected $authCodeManager; + /** * @var RefreshTokenManagerInterface */ @@ -87,13 +94,15 @@ protected function setUp() $this->clientManager = new ClientManager(); $this->accessTokenManager = new AccessTokenManager(); $this->refreshTokenManager = new RefreshTokenManager(); + $this->authCodeManager = new AuthCodeManager(); $this->eventDispatcher = new EventDispatcher(); FixtureFactory::initializeFixtures( $this->scopeManager, $this->clientManager, $this->accessTokenManager, - $this->refreshTokenManager + $this->refreshTokenManager, + $this->authCodeManager ); $scopeConverter = new ScopeConverter(); diff --git a/Tests/TestHelper.php b/Tests/TestHelper.php index bdd2cd01..ed748d92 100644 --- a/Tests/TestHelper.php +++ b/Tests/TestHelper.php @@ -12,6 +12,7 @@ use Trikoder\Bundle\OAuth2Bundle\League\Entity\Client as ClientEntity; use Trikoder\Bundle\OAuth2Bundle\League\Entity\Scope as ScopeEntity; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken as AccessTokenModel; +use Trikoder\Bundle\OAuth2Bundle\Model\AuthCode as AuthCodeModel; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken as RefreshTokenModel; final class TestHelper @@ -38,6 +39,26 @@ public static function generateEncryptedPayload(RefreshTokenModel $refreshToken) } } + public static function generateEncryptedAuthCodePayload(AuthCodeModel $authCode): ?string + { + $payload = json_encode([ + 'client_id' => $authCode->getClient()->getIdentifier(), + 'redirect_uri' => (string) $authCode->getClient()->getRedirectUris()[0], + 'auth_code_id' => $authCode->getIdentifier(), + 'scopes' => $authCode->getScopes(), + 'user_id' => $authCode->getUserIdentifier(), + 'expire_time' => $authCode->getExpiryDateTime()->getTimestamp(), + 'code_challenge' => null, + 'code_challenge_method' => null, + ]); + + try { + return Crypto::encryptWithPassword($payload, self::ENCRYPTION_KEY); + } catch (CryptoException $e) { + return null; + } + } + public static function generateJwtToken(AccessTokenModel $accessToken): string { $clientEntity = new ClientEntity(); diff --git a/Tests/TestKernel.php b/Tests/TestKernel.php index 72fde023..c26fbc41 100644 --- a/Tests/TestKernel.php +++ b/Tests/TestKernel.php @@ -8,7 +8,9 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Routing\RouteCollectionBuilder; +use Symfony\Component\Security\Core\User\UserInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Manager\AuthCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; @@ -88,6 +90,11 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa $container->loadFromExtension('security', [ 'firewalls' => [ + 'auth' => [ + 'pattern' => '^/authorize', + 'stateless' => true, + 'http_basic' => true, + ], 'test' => [ 'pattern' => '^/security-test', 'stateless' => true, @@ -99,12 +106,16 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa 'memory' => [ 'users' => [ FixtureFactory::FIXTURE_USER => [ + 'password' => FixtureFactory::FIXTURE_PASSWORD, 'roles' => ['ROLE_USER'], ], ], ], ], ], + 'encoders' => [ + UserInterface::class => 'plaintext', + ], ]); $container->loadFromExtension('sensio_framework_extra', [ @@ -194,5 +205,14 @@ public function process(ContainerBuilder $container) ) ->setPublic(true) ; + + $container + ->getDefinition( + $container + ->getAlias(AuthCodeManagerInterface::class) + ->setPublic(true) + ) + ->setPublic(true) + ; } } From b4e41c534b51b6d880776cf2087ffdc175766e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Fri, 18 Jan 2019 15:01:45 +0100 Subject: [PATCH 02/44] Add some integration tests --- Tests/Integration/AbstractIntegrationTest.php | 42 ++++++++++- Tests/Integration/AuthorizationServerTest.php | 73 +++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/Tests/Integration/AbstractIntegrationTest.php b/Tests/Integration/AbstractIntegrationTest.php index 4e779951..d431ca79 100644 --- a/Tests/Integration/AbstractIntegrationTest.php +++ b/Tests/Integration/AbstractIntegrationTest.php @@ -2,15 +2,18 @@ namespace Trikoder\Bundle\OAuth2Bundle\Tests\Integration; +use DateInterval; use Defuse\Crypto\Crypto; use Defuse\Crypto\Exception\CryptoException; use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\CryptKey; use League\OAuth2\Server\Exception\OAuthServerException; +use League\OAuth2\Server\Grant\AuthCodeGrant; use League\OAuth2\Server\Grant\ClientCredentialsGrant; use League\OAuth2\Server\Grant\PasswordGrant; use League\OAuth2\Server\Grant\RefreshTokenGrant; use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; +use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface; use League\OAuth2\Server\Repositories\ClientRepositoryInterface; use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; @@ -21,7 +24,9 @@ use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Trikoder\Bundle\OAuth2Bundle\Converter\ScopeConverter; +use Trikoder\Bundle\OAuth2Bundle\League\Entity\User; use Trikoder\Bundle\OAuth2Bundle\League\Repository\AccessTokenRepository; +use Trikoder\Bundle\OAuth2Bundle\League\Repository\AuthCodeRepository; use Trikoder\Bundle\OAuth2Bundle\League\Repository\ClientRepository; use Trikoder\Bundle\OAuth2Bundle\League\Repository\RefreshTokenRepository; use Trikoder\Bundle\OAuth2Bundle\League\Repository\ScopeRepository; @@ -111,13 +116,15 @@ protected function setUp() $accessTokenRepository = new AccessTokenRepository($this->accessTokenManager, $this->clientManager, $scopeConverter); $refreshTokenRepository = new RefreshTokenRepository($this->refreshTokenManager, $this->accessTokenManager); $userRepository = new UserRepository($this->clientManager, $this->eventDispatcher); + $authCodeRepository = new AuthCodeRepository($this->authCodeManager, $this->clientManager, $scopeConverter); $this->authorizationServer = $this->createAuthorizationServer( $scopeRepository, $clientRepository, $accessTokenRepository, $refreshTokenRepository, - $userRepository + $userRepository, + $authCodeRepository ); $this->resourceServer = $this->createResourceServer($accessTokenRepository); @@ -171,6 +178,15 @@ protected function createResourceRequest(string $jwtToken): ServerRequestInterfa return new ServerRequest([], [], null, null, 'php://temp', $headers); } + protected function createAuthorizeRequest(?string $credentials, array $query = []): ServerRequestInterface + { + $headers = [ + 'Authorization' => sprintf('Basic %s', base64_encode($credentials)), + ]; + + return new ServerRequest([], [], null, null, 'php://temp', $headers, [], $query, ''); + } + protected function handleAuthorizationRequest(ServerRequestInterface $serverRequest): array { $response = new Response(); @@ -195,12 +211,33 @@ protected function handleResourceRequest(ServerRequestInterface $serverRequest): return $serverRequest; } + protected function handleAuthorizeRequest(ServerRequestInterface $serverRequest, $approved = true): array + { + $response = new Response(); + + try { + $authRequest = $this->authorizationServer->validateAuthorizationRequest($serverRequest); + $authRequest->setUser(new User('user')); + $authRequest->setAuthorizationApproved($approved); + + $response = $this->authorizationServer->completeAuthorizationRequest($authRequest, $response); + } catch (OAuthServerException $e) { + $response = $e->generateHttpResponse($response); + } + + $data = []; + parse_str(parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $data); + + return $data; + } + private function createAuthorizationServer( ScopeRepositoryInterface $scopeRepository, ClientRepositoryInterface $clientRepository, AccessTokenRepositoryInterface $accessTokenRepository, RefreshTokenRepositoryInterface $refreshTokenRepository, - UserRepositoryInterface $userRepository + UserRepositoryInterface $userRepository, + AuthCodeRepositoryInterface $authCodeRepository ): AuthorizationServer { $authorizationServer = new AuthorizationServer( $clientRepository, @@ -213,6 +250,7 @@ private function createAuthorizationServer( $authorizationServer->enableGrantType(new ClientCredentialsGrant()); $authorizationServer->enableGrantType(new RefreshTokenGrant($refreshTokenRepository)); $authorizationServer->enableGrantType(new PasswordGrant($userRepository, $refreshTokenRepository)); + $authorizationServer->enableGrantType(new AuthCodeGrant($authCodeRepository, $refreshTokenRepository, new DateInterval('PT10M'))); return $authorizationServer; } diff --git a/Tests/Integration/AuthorizationServerTest.php b/Tests/Integration/AuthorizationServerTest.php index fa7a9aac..5ced5984 100644 --- a/Tests/Integration/AuthorizationServerTest.php +++ b/Tests/Integration/AuthorizationServerTest.php @@ -383,4 +383,77 @@ public function testInvalidPayloadRefreshGrant(): void $this->assertSame('The refresh token is invalid.', $response['message']); $this->assertSame('Cannot decrypt the refresh token', $response['hint']); } + + public function testSuccessfulCodeRequest(): void + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'foo', + ]); + + $response = $this->handleAuthorizeRequest($request); + + // Response assertions. + $this->assertArrayHasKey('code', $response); + } + + public function testSuccessfulCodeRequestWithState(): void + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'foo', + 'state' => 'quzbaz', + ]); + + $response = $this->handleAuthorizeRequest($request); + + // Response assertions. + $this->assertArrayHasKey('code', $response); + $this->assertSame('quzbaz', $response['state']); + } + + public function testSuccessfulCodeRequestWithRedirectUri(): void + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'foo', + 'redirect-uri' => 'https://example.org/oauth2/redirect-uri', + ]); + + $response = $this->handleAuthorizeRequest($request); + + // Response assertions. + $this->assertArrayHasKey('code', $response); + } + + public function testCodeRequestWithInvalidScope(): void + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'foo', + 'scope' => 'non_existing', + ]); + + $response = $this->handleAuthorizeRequest($request); + + // Response assertions. + $this->assertSame('invalid_scope', $response['error']); + $this->assertSame('The requested scope is invalid, unknown, or malformed', $response['message']); + $this->assertSame('Check the `non_existing` scope', $response['hint']); + } + + public function testDeniedCodeRequest(): void + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'foo', + ]); + + $response = $this->handleAuthorizeRequest($request, false); + + // Response assertions. + $this->assertSame('access_denied', $response['error']); + $this->assertSame('The resource owner or authorization server denied the request.', $response['message']); + $this->assertSame('The user denied the request', $response['hint']); + } } From 34d6e4ec36f07e8db39db9777ebaeac67b20f792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Mon, 21 Jan 2019 09:09:26 +0100 Subject: [PATCH 03/44] Add final integration tests --- Tests/Fixtures/FixtureFactory.php | 18 +++ Tests/Integration/AbstractIntegrationTest.php | 4 + Tests/Integration/AuthorizationServerTest.php | 131 +++++++++++++++++- 3 files changed, 152 insertions(+), 1 deletion(-) diff --git a/Tests/Fixtures/FixtureFactory.php b/Tests/Fixtures/FixtureFactory.php index 7dd71c95..1a8092bd 100644 --- a/Tests/Fixtures/FixtureFactory.php +++ b/Tests/Fixtures/FixtureFactory.php @@ -32,6 +32,8 @@ final class FixtureFactory public const FIXTURE_REFRESH_TOKEN_REVOKED = '63641841630c2e4d747e0f9ebe12ee04424e322874b8e68ef69fd58f1899ef70beb09733e23928a6'; public const FIXTURE_AUTH_CODE = '0aa70e8152259988b3c8e9e8cff604019bb986eb226bd126da189829b95a2be631e2506042064e12'; + public const FIXTURE_AUTH_CODE_DIFFERENT_CLIENT = 'e8fe264053cb346f4437af05c8cc9036931cfec3a0d5b54bdae349304ca4a83fd2f4590afd51e559'; + public const FIXTURE_AUTH_CODE_EXPIRED = 'a7bdbeb26c9f095d842f5e5b8e313b24318d6b26728d1c543136727bbe9525f7a7930305a09b7401'; public const FIXTURE_CLIENT_FIRST = 'foo'; public const FIXTURE_CLIENT_SECOND = 'bar'; @@ -191,6 +193,22 @@ public static function createAuthCodes(ClientManagerInterface $clientManager): a [] ); + $authCodes[] = new AuthCode( + self::FIXTURE_AUTH_CODE_DIFFERENT_CLIENT, + new DateTime('+2 minute'), + $clientManager->find(self::FIXTURE_CLIENT_SECOND), + self::FIXTURE_USER, + [] + ); + + $authCodes[] = new AuthCode( + self::FIXTURE_AUTH_CODE_EXPIRED, + new DateTime('-30 minute'), + $clientManager->find(self::FIXTURE_CLIENT_FIRST), + self::FIXTURE_USER, + [] + ); + return $authCodes; } diff --git a/Tests/Integration/AbstractIntegrationTest.php b/Tests/Integration/AbstractIntegrationTest.php index d431ca79..1a1293d8 100644 --- a/Tests/Integration/AbstractIntegrationTest.php +++ b/Tests/Integration/AbstractIntegrationTest.php @@ -225,6 +225,10 @@ protected function handleAuthorizeRequest(ServerRequestInterface $serverRequest, $response = $e->generateHttpResponse($response); } + if (!$response->hasHeader('Location')) { + return json_decode($response->getBody(), true); + } + $data = []; parse_str(parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $data); diff --git a/Tests/Integration/AuthorizationServerTest.php b/Tests/Integration/AuthorizationServerTest.php index 5ced5984..1c0dc650 100644 --- a/Tests/Integration/AuthorizationServerTest.php +++ b/Tests/Integration/AuthorizationServerTest.php @@ -417,7 +417,7 @@ public function testSuccessfulCodeRequestWithRedirectUri(): void $request = $this->createAuthorizeRequest(null, [ 'response_type' => 'code', 'client_id' => 'foo', - 'redirect-uri' => 'https://example.org/oauth2/redirect-uri', + 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', ]); $response = $this->handleAuthorizeRequest($request); @@ -442,6 +442,21 @@ public function testCodeRequestWithInvalidScope(): void $this->assertSame('Check the `non_existing` scope', $response['hint']); } + public function testCodeRequestWithInvalidRedirectUri(): void + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'foo', + 'redirect_uri' => 'https://example.org/oauth2/other-uri', + ]); + + $response = $this->handleAuthorizeRequest($request); + + // Response assertions. + $this->assertSame('invalid_client', $response['error']); + $this->assertSame('Client authentication failed', $response['message']); + } + public function testDeniedCodeRequest(): void { $request = $this->createAuthorizeRequest(null, [ @@ -456,4 +471,118 @@ public function testDeniedCodeRequest(): void $this->assertSame('The resource owner or authorization server denied the request.', $response['message']); $this->assertSame('The user denied the request', $response['hint']); } + + public function testCodeRequestWithMissingClient(): void + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'yolo', + ]); + + $response = $this->handleAuthorizeRequest($request, false); + + // Response assertions. + $this->assertSame('invalid_client', $response['error']); + $this->assertSame('Client authentication failed', $response['message']); + } + + public function testCodeRequestWithInactiveClient(): void + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'baz_inactive', + ]); + + $response = $this->handleAuthorizeRequest($request, false); + + // Response assertions. + $this->assertSame('invalid_client', $response['error']); + $this->assertSame('Client authentication failed', $response['message']); + } + + public function testCodeRequestWithRestrictedGrantClient(): void + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'qux_restricted', + ]); + + $response = $this->handleAuthorizeRequest($request, false); + + // Response assertions. + $this->assertSame('invalid_client', $response['error']); + $this->assertSame('Client authentication failed', $response['message']); + } + + public function testSuccessfulAuthorizationWithCode(): void + { + $existingAuthCode = $this->authCodeManager->find(FixtureFactory::FIXTURE_AUTH_CODE); + + $request = $this->createAuthorizationRequest('foo:secret', [ + 'grant_type' => 'authorization_code', + 'code' => TestHelper::generateEncryptedAuthCodePayload($existingAuthCode), + 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', + ]); + + $response = $this->handleAuthorizationRequest($request); + $accessToken = $this->getAccessToken($response['access_token']); + + $this->assertSame('Bearer', $response['token_type']); + $this->assertSame(3600, $response['expires_in']); + $this->assertInstanceOf(AccessToken::class, $accessToken); + $this->assertSame('foo', $accessToken->getClient()->getIdentifier()); + } + + public function testFailedAuthorizationWithCodeForOtherClient(): void + { + $existingAuthCode = $this->authCodeManager->find(FixtureFactory::FIXTURE_AUTH_CODE_DIFFERENT_CLIENT); + + $request = $this->createAuthorizationRequest('foo:secret', [ + 'grant_type' => 'authorization_code', + 'code' => TestHelper::generateEncryptedAuthCodePayload($existingAuthCode), + 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', + ]); + + $response = $this->handleAuthorizationRequest($request); + + // Response assertions. + $this->assertSame('invalid_request', $response['error']); + $this->assertSame('The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.', $response['message']); + $this->assertSame('Authorization code was not issued to this client', $response['hint']); + } + + public function testFailedAuthorizationWithExpiredCode(): void + { + $existingAuthCode = $this->authCodeManager->find(FixtureFactory::FIXTURE_AUTH_CODE_EXPIRED); + + $request = $this->createAuthorizationRequest('foo:secret', [ + 'grant_type' => 'authorization_code', + 'code' => TestHelper::generateEncryptedAuthCodePayload($existingAuthCode), + 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', + ]); + + $response = $this->handleAuthorizationRequest($request); + + // Response assertions. + $this->assertSame('invalid_request', $response['error']); + $this->assertSame('The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.', $response['message']); + $this->assertSame('Authorization code has expired', $response['hint']); + } + + public function testFailedAuthorizationWithInvalidRedirectUri(): void + { + $existingAuthCode = $this->authCodeManager->find(FixtureFactory::FIXTURE_AUTH_CODE); + + $request = $this->createAuthorizationRequest('foo:secret', [ + 'grant_type' => 'authorization_code', + 'code' => TestHelper::generateEncryptedAuthCodePayload($existingAuthCode), + 'redirect_uri' => 'https://example.org/oauth2/other-uri', + ]); + + $response = $this->handleAuthorizationRequest($request); + + // Response assertions. + $this->assertSame('invalid_client', $response['error']); + $this->assertSame('Client authentication failed', $response['message']); + } } From 6aace213013653d67e36fc83ea3a6d65ae942a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Mon, 21 Jan 2019 15:14:03 +0100 Subject: [PATCH 04/44] Use an event to resolve authorization request An event listener shoud allow or deny the authorization request, or provide an URI where the user will be redirected to view the authorization interface --- Controller/AuthorizationController.php | 23 ++- Event/AuthorizationRequestResolveEvent.php | 136 ++++++++++++++++++ OAuth2Events.php | 9 ++ Resources/config/services.xml | 4 +- .../Voter/AlwaysApproveAuthRequestVoter.php | 20 --- .../Acceptance/AuthorizationEndpointTest.php | 46 ++++++ composer.json | 8 +- 7 files changed, 219 insertions(+), 27 deletions(-) create mode 100644 Event/AuthorizationRequestResolveEvent.php delete mode 100644 Security/Authorization/Voter/AlwaysApproveAuthRequestVoter.php diff --git a/Controller/AuthorizationController.php b/Controller/AuthorizationController.php index bbcf1a8a..5b3cd8b3 100644 --- a/Controller/AuthorizationController.php +++ b/Controller/AuthorizationController.php @@ -7,10 +7,13 @@ use LogicException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Trikoder\Bundle\OAuth2Bundle\Event\AuthorizationRequestResolveEvent; use Trikoder\Bundle\OAuth2Bundle\League\Entity\User; +use Trikoder\Bundle\OAuth2Bundle\OAuth2Events; use Zend\Diactoros\Response; final class AuthorizationController @@ -30,11 +33,17 @@ final class AuthorizationController */ private $tokenStorage; - public function __construct(AuthorizationServer $server, AuthorizationCheckerInterface $authorizationChecker, TokenStorageInterface $tokenStorage) + /** + * @var EventDispatcherInterface + */ + private $eventDispatcher; + + public function __construct(AuthorizationServer $server, AuthorizationCheckerInterface $authorizationChecker, TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher) { $this->server = $server; $this->authorizationChecker = $authorizationChecker; $this->tokenStorage = $tokenStorage; + $this->eventDispatcher = $eventDispatcher; } public function indexAction(ServerRequestInterface $serverRequest): ResponseInterface @@ -48,7 +57,17 @@ public function indexAction(ServerRequestInterface $serverRequest): ResponseInte try { $authRequest = $this->server->validateAuthorizationRequest($serverRequest); $authRequest->setUser($this->getUserEntity()); - $authRequest->setAuthorizationApproved($this->authorizationChecker->isGranted($authRequest)); + + $event = $this->eventDispatcher->dispatch( + OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, + new AuthorizationRequestResolveEvent($authRequest) + ); + + if (null === $event->isAuthorizationAllowed()) { + return $serverResponse->withStatus(302)->withHeader('Location', $event->getDecisionUri()); + } + + $authRequest->setAuthorizationApproved($event->isAuthorizationAllowed()); return $this->server->completeAuthorizationRequest($authRequest, $serverResponse); } catch (OAuthServerException $e) { diff --git a/Event/AuthorizationRequestResolveEvent.php b/Event/AuthorizationRequestResolveEvent.php new file mode 100644 index 00000000..67bc0cda --- /dev/null +++ b/Event/AuthorizationRequestResolveEvent.php @@ -0,0 +1,136 @@ +authorizationRequest = $authorizationRequest; + } + + /** + * @return bool + */ + public function isAuthorizationAllowed(): ?bool + { + return $this->authorizationAllowed; + } + + /** + * @param bool $authorizationAllowed + */ + public function setAuthorizationAllowed(?bool $authorizationAllowed) + { + $this->authorizationAllowed = $authorizationAllowed; + } + + public function getDecisionUri(): string + { + if (null === $this->decisionUri) { + throw new LogicException('There is no decision URI. If the authorization request is not allowed nor denied, a decision URI should be provided'); + } + + return $this->decisionUri; + } + + public function setDecisionUri(string $decisionUri) + { + $this->decisionUri = $decisionUri; + } + + /** + * @return string + */ + public function getGrantTypeId() + { + return $this->authorizationRequest->getGrantTypeId(); + } + + /** + * @return ClientEntityInterface + */ + public function getClient() + { + return $this->authorizationRequest->getClient(); + } + + /** + * @return UserEntityInterface + */ + public function getUser() + { + return $this->authorizationRequest->getUser(); + } + + /** + * @return ScopeEntityInterface[] + */ + public function getScopes() + { + return $this->authorizationRequest->getScopes(); + } + + /** + * @return bool + */ + public function isAuthorizationApproved() + { + return $this->authorizationRequest->isAuthorizationApproved(); + } + + /** + * @return string|null + */ + public function getRedirectUri() + { + return $this->authorizationRequest->getRedirectUri(); + } + + /** + * @return string|null + */ + public function getState() + { + return $this->authorizationRequest->getState(); + } + + /** + * @return string + */ + public function getCodeChallenge() + { + return $this->authorizationRequest->getCodeChallenge(); + } + + /** + * @return string + */ + public function getCodeChallengeMethod() + { + return $this->authorizationRequest->getCodeChallengeMethod(); + } +} diff --git a/OAuth2Events.php b/OAuth2Events.php index 10e64a56..4f859ed5 100644 --- a/OAuth2Events.php +++ b/OAuth2Events.php @@ -19,4 +19,13 @@ final class OAuth2Events * You could alter the access token's scope here. */ public const SCOPE_RESOLVE = 'trikoder.oauth2.scope_resolve'; + + /** + * The AUTHORIZATION_REQUEST_RESOLVE event occurrs right before the system + * complete the authorization request. + * + * You could allow or deny the authorization request, or set the uri where + * the user should decide about it. + */ + public const AUTHORIZATION_REQUEST_RESOLVE = 'trikoder.oauth2.authorization_request_resolve'; } diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 34df0f60..6d1c7379 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -46,9 +46,6 @@ - - - @@ -85,6 +82,7 @@ + diff --git a/Security/Authorization/Voter/AlwaysApproveAuthRequestVoter.php b/Security/Authorization/Voter/AlwaysApproveAuthRequestVoter.php deleted file mode 100644 index 8104de9c..00000000 --- a/Security/Authorization/Voter/AlwaysApproveAuthRequestVoter.php +++ /dev/null @@ -1,20 +0,0 @@ -client + ->getContainer() + ->get('event_dispatcher') + ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event) { + $event->setAuthorizationAllowed(true); + }); + timecop_freeze(new DateTime()); $this->client->request( @@ -41,6 +50,43 @@ public function testSuccessfulCodeRequest() $this->assertEquals('foobar', $query['state']); } + public function testCodeRequestRedirectToDecision() + { + $this->client + ->getContainer() + ->get('event_dispatcher') + ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event) { + $event->setDecisionUri('/authorize/decision'); + }); + + timecop_freeze(new DateTime()); + + $this->client->request( + 'GET', + '/authorize', + [ + 'client_id' => FixtureFactory::FIXTURE_CLIENT_FIRST, + 'response_type' => 'code', + 'state' => 'foobar', + 'redirect_uri' => FixtureFactory::FIXTURE_CLIENT_FIRST_REDIRECT_URI, + 'scope' => FixtureFactory::FIXTURE_SCOPE_FIRST . ' ' . FixtureFactory::FIXTURE_SCOPE_SECOND, + ], + [], + [ + 'PHP_AUTH_USER' => FixtureFactory::FIXTURE_USER, + 'PHP_AUTH_PW' => FixtureFactory::FIXTURE_PASSWORD, + ] + ); + + timecop_return(); + + $response = $this->client->getResponse(); + + $this->assertSame(302, $response->getStatusCode()); + $redirectUri = $response->headers->get('Location'); + $this->assertEquals('/authorize/decision', $redirectUri); + } + public function testFailedAuthorizeRequest() { $this->client->request( diff --git a/composer.json b/composer.json index 79ad29eb..2180b97a 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,9 @@ "friendsofphp/php-cs-fixer": "2.13.1", "phpunit/phpunit": "7.4.*", "symfony/browser-kit": "~3.4|~4.0", - "symfony/phpunit-bridge": "~4.0" + "symfony/phpunit-bridge": "~4.0", + "symfony/twig-bundle": "~3.4|~4.0", + "symfony/form": "~3.4|~4.0" }, "autoload": { "psr-4": { "Trikoder\\Bundle\\OAuth2Bundle\\": "" }, @@ -41,6 +43,8 @@ "test": "phpunit" }, "suggest": { - "nelmio/cors-bundle": "For handling CORS requests" + "nelmio/cors-bundle": "For handling CORS requests", + "symfony/form": "For the default decision interface", + "symfony/twig-bundle": "For the default decision interface" } } From 4e8a5084726ad4e5c44e46348f2a59c17f7307e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Tue, 22 Jan 2019 13:23:15 +0100 Subject: [PATCH 05/44] Minor fixes --- Tests/Integration/AbstractIntegrationTest.php | 4 +++- Tests/TestHelper.php | 21 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Tests/Integration/AbstractIntegrationTest.php b/Tests/Integration/AbstractIntegrationTest.php index 1a1293d8..1d85a6e6 100644 --- a/Tests/Integration/AbstractIntegrationTest.php +++ b/Tests/Integration/AbstractIntegrationTest.php @@ -217,7 +217,9 @@ protected function handleAuthorizeRequest(ServerRequestInterface $serverRequest, try { $authRequest = $this->authorizationServer->validateAuthorizationRequest($serverRequest); - $authRequest->setUser(new User('user')); + $user = new User(); + $user->setIdentifier('user'); + $authRequest->setUser($user); $authRequest->setAuthorizationApproved($approved); $response = $this->authorizationServer->completeAuthorizationRequest($authRequest, $response); diff --git a/Tests/TestHelper.php b/Tests/TestHelper.php index ed748d92..b170f3c5 100644 --- a/Tests/TestHelper.php +++ b/Tests/TestHelper.php @@ -14,6 +14,7 @@ use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken as AccessTokenModel; use Trikoder\Bundle\OAuth2Bundle\Model\AuthCode as AuthCodeModel; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken as RefreshTokenModel; +use Trikoder\Bundle\OAuth2Bundle\Model\Scope as ScopeModel; final class TestHelper { @@ -45,7 +46,7 @@ public static function generateEncryptedAuthCodePayload(AuthCodeModel $authCode) 'client_id' => $authCode->getClient()->getIdentifier(), 'redirect_uri' => (string) $authCode->getClient()->getRedirectUris()[0], 'auth_code_id' => $authCode->getIdentifier(), - 'scopes' => $authCode->getScopes(), + 'scopes' => self::getScopesEntities($authCode->getScopes()), 'user_id' => $authCode->getUserIdentifier(), 'expire_time' => $authCode->getExpiryDateTime()->getTimestamp(), 'code_challenge' => null, @@ -59,6 +60,24 @@ public static function generateEncryptedAuthCodePayload(AuthCodeModel $authCode) } } + /** + * @param ScopeModel[] $scopes + * + * @return ScopeEntity[] + */ + private static function getScopesEntities(array $scopes): array + { + return array_map( + function (ScopeModel $scope) { + $entity = new ScopeEntity(); + $entity->setIdentifier((string) $scope); + + return $entity; + }, + $scopes + ); + } + public static function generateJwtToken(AccessTokenModel $accessToken): string { $clientEntity = new ClientEntity(); From b1b67e8fb214666546f73960410af2e9b53ab283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Tue, 22 Jan 2019 13:42:23 +0100 Subject: [PATCH 06/44] Remove unused dependencies --- composer.json | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 2180b97a..79ad29eb 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,7 @@ "friendsofphp/php-cs-fixer": "2.13.1", "phpunit/phpunit": "7.4.*", "symfony/browser-kit": "~3.4|~4.0", - "symfony/phpunit-bridge": "~4.0", - "symfony/twig-bundle": "~3.4|~4.0", - "symfony/form": "~3.4|~4.0" + "symfony/phpunit-bridge": "~4.0" }, "autoload": { "psr-4": { "Trikoder\\Bundle\\OAuth2Bundle\\": "" }, @@ -43,8 +41,6 @@ "test": "phpunit" }, "suggest": { - "nelmio/cors-bundle": "For handling CORS requests", - "symfony/form": "For the default decision interface", - "symfony/twig-bundle": "For the default decision interface" + "nelmio/cors-bundle": "For handling CORS requests" } } From a79d41edbb70783f0e3fea42435c7e75809674ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Tue, 22 Jan 2019 14:29:22 +0100 Subject: [PATCH 07/44] Use ScopeConverter to convert scopes --- Tests/TestHelper.php | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/Tests/TestHelper.php b/Tests/TestHelper.php index b170f3c5..5724c11a 100644 --- a/Tests/TestHelper.php +++ b/Tests/TestHelper.php @@ -8,13 +8,13 @@ use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; +use Trikoder\Bundle\OAuth2Bundle\Converter\ScopeConverter; use Trikoder\Bundle\OAuth2Bundle\League\Entity\AccessToken as AccessTokenEntity; use Trikoder\Bundle\OAuth2Bundle\League\Entity\Client as ClientEntity; use Trikoder\Bundle\OAuth2Bundle\League\Entity\Scope as ScopeEntity; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken as AccessTokenModel; use Trikoder\Bundle\OAuth2Bundle\Model\AuthCode as AuthCodeModel; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken as RefreshTokenModel; -use Trikoder\Bundle\OAuth2Bundle\Model\Scope as ScopeModel; final class TestHelper { @@ -46,7 +46,7 @@ public static function generateEncryptedAuthCodePayload(AuthCodeModel $authCode) 'client_id' => $authCode->getClient()->getIdentifier(), 'redirect_uri' => (string) $authCode->getClient()->getRedirectUris()[0], 'auth_code_id' => $authCode->getIdentifier(), - 'scopes' => self::getScopesEntities($authCode->getScopes()), + 'scopes' => (new ScopeConverter())->toDomainArray($authCode->getScopes()), 'user_id' => $authCode->getUserIdentifier(), 'expire_time' => $authCode->getExpiryDateTime()->getTimestamp(), 'code_challenge' => null, @@ -60,24 +60,6 @@ public static function generateEncryptedAuthCodePayload(AuthCodeModel $authCode) } } - /** - * @param ScopeModel[] $scopes - * - * @return ScopeEntity[] - */ - private static function getScopesEntities(array $scopes): array - { - return array_map( - function (ScopeModel $scope) { - $entity = new ScopeEntity(); - $entity->setIdentifier((string) $scope); - - return $entity; - }, - $scopes - ); - } - public static function generateJwtToken(AccessTokenModel $accessToken): string { $clientEntity = new ClientEntity(); From 87ab8cc315be2116dcd5b31a5a24739e66dbfe3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Thu, 24 Jan 2019 11:49:22 +0100 Subject: [PATCH 08/44] Improve naming and semantics --- Controller/AuthorizationController.php | 6 +++-- Event/AuthorizationRequestResolveEvent.php | 23 ++++++++----------- OAuth2Events.php | 2 +- .../Acceptance/AuthorizationEndpointTest.php | 2 +- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/Controller/AuthorizationController.php b/Controller/AuthorizationController.php index 5b3cd8b3..ede08d20 100644 --- a/Controller/AuthorizationController.php +++ b/Controller/AuthorizationController.php @@ -63,11 +63,13 @@ public function indexAction(ServerRequestInterface $serverRequest): ResponseInte new AuthorizationRequestResolveEvent($authRequest) ); - if (null === $event->isAuthorizationAllowed()) { + if (AuthorizationRequestResolveEvent::AUTHORIZATION_PENDING === $event->getAuhorizationResolution()) { return $serverResponse->withStatus(302)->withHeader('Location', $event->getDecisionUri()); } - $authRequest->setAuthorizationApproved($event->isAuthorizationAllowed()); + if (AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED === $event->getAuhorizationResolution()) { + $authRequest->setAuthorizationApproved(true); + } return $this->server->completeAuthorizationRequest($authRequest, $serverResponse); } catch (OAuthServerException $e) { diff --git a/Event/AuthorizationRequestResolveEvent.php b/Event/AuthorizationRequestResolveEvent.php index 67bc0cda..b2c4b77d 100644 --- a/Event/AuthorizationRequestResolveEvent.php +++ b/Event/AuthorizationRequestResolveEvent.php @@ -8,7 +8,7 @@ final class AuthorizationRequestResolveEvent extends Event { - public const AUTHORIZATION_ALLOWED = true; + public const AUTHORIZATION_APPROVED = true; public const AUTHORIZATION_DENIED = false; public const AUTHORIZATION_PENDING = null; @@ -18,14 +18,14 @@ final class AuthorizationRequestResolveEvent extends Event private $authorizationRequest; /** - * @var string + * @var ?string */ private $decisionUri; /** - * @var bool + * @var ?bool */ - private $authorizationAllowed; + private $authorizationResolution; public function __construct(AuthorizationRequest $authorizationRequest) { @@ -33,25 +33,22 @@ public function __construct(AuthorizationRequest $authorizationRequest) } /** - * @return bool + * @return ?bool */ - public function isAuthorizationAllowed(): ?bool + public function getAuhorizationResolution(): ?bool { - return $this->authorizationAllowed; + return $this->authorizationResolution; } - /** - * @param bool $authorizationAllowed - */ - public function setAuthorizationAllowed(?bool $authorizationAllowed) + public function resolveAuthorization(bool $authorizationResolution) { - $this->authorizationAllowed = $authorizationAllowed; + $this->authorizationResolution = $authorizationResolution; } public function getDecisionUri(): string { if (null === $this->decisionUri) { - throw new LogicException('There is no decision URI. If the authorization request is not allowed nor denied, a decision URI should be provided'); + throw new LogicException('There is no decision URI. If the authorization request is not approved nor denied, a decision URI should be provided'); } return $this->decisionUri; diff --git a/OAuth2Events.php b/OAuth2Events.php index 4f859ed5..aa31cd8e 100644 --- a/OAuth2Events.php +++ b/OAuth2Events.php @@ -24,7 +24,7 @@ final class OAuth2Events * The AUTHORIZATION_REQUEST_RESOLVE event occurrs right before the system * complete the authorization request. * - * You could allow or deny the authorization request, or set the uri where + * You could approve or deny the authorization request, or set the uri where * the user should decide about it. */ public const AUTHORIZATION_REQUEST_RESOLVE = 'trikoder.oauth2.authorization_request_resolve'; diff --git a/Tests/Acceptance/AuthorizationEndpointTest.php b/Tests/Acceptance/AuthorizationEndpointTest.php index 9f184c32..f4a1c73d 100644 --- a/Tests/Acceptance/AuthorizationEndpointTest.php +++ b/Tests/Acceptance/AuthorizationEndpointTest.php @@ -15,7 +15,7 @@ public function testSuccessfulCodeRequest() ->getContainer() ->get('event_dispatcher') ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event) { - $event->setAuthorizationAllowed(true); + $event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED); }); timecop_freeze(new DateTime()); From c89cfb8316024ff56cc472fbeeef41a4d44ffb83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Tue, 22 Jan 2019 13:23:33 +0100 Subject: [PATCH 09/44] Adds "OpenID Connect" support --- Controller/UserInfoController.php | 62 ++++++++++++++++ DependencyInjection/Configuration.php | 20 +++++ .../TrikoderOAuth2Extension.php | 11 +++ Event/ClaimsResolveEvent.php | 43 +++++++++++ OAuth2Events.php | 8 ++ OpenIDConnect/Entity/Identity.php | 24 ++++++ OpenIDConnect/Repository/IdentityProvider.php | 37 ++++++++++ Resources/config/routes.xml | 1 + Resources/config/services.xml | 18 +++++ Tests/Acceptance/TokenEndpointTest.php | 32 ++++++++ Tests/Acceptance/UserInfoEndpointTest.php | 74 +++++++++++++++++++ Tests/Fixtures/FixtureFactory.php | 11 +++ Tests/Integration/AbstractIntegrationTest.php | 21 +++++- Tests/Integration/OpenIDProviderTest.php | 35 +++++++++ Tests/TestHelper.php | 2 +- Tests/TestKernel.php | 3 + composer.json | 6 +- 17 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 Controller/UserInfoController.php create mode 100644 Event/ClaimsResolveEvent.php create mode 100644 OpenIDConnect/Entity/Identity.php create mode 100644 OpenIDConnect/Repository/IdentityProvider.php create mode 100644 Tests/Acceptance/UserInfoEndpointTest.php create mode 100644 Tests/Integration/OpenIDProviderTest.php diff --git a/Controller/UserInfoController.php b/Controller/UserInfoController.php new file mode 100644 index 00000000..decf0b12 --- /dev/null +++ b/Controller/UserInfoController.php @@ -0,0 +1,62 @@ +server = $server; + $this->identityProvider = $identityProvider; + $this->claimExtractor = $claimExtractor; + } + + public function indexAction(ServerRequestInterface $serverRequest) + { + $request = $this->serverRequestWithBearerToken($serverRequest); + + try { + $validatedRequest = $this->server->validateAuthenticatedRequest($request); + } catch (OAuthServerException $e) { + return $e->generateHttpResponse(new Psr7Response()); + } + + $userEntity = $this->identityProvider->getUserEntityByIdentifier($validatedRequest->getAttribute('oauth_user_id')); + $claims = $this->claimExtractor->extract($validatedRequest->getAttribute('oauth_scopes', []), $userEntity->getClaims()); + + return new JsonResponse(['sub' => $userEntity->getIdentifier()] + $claims); + } + + private function serverRequestWithBearerToken(ServerRequestInterface $serverRequest): ServerRequestInterface + { + if ($serverRequest->hasHeader('Authorization')) { + return $serverRequest; + } + + if ('POST' !== $serverRequest->getMethod()) { + return $serverRequest; + } + + if (!\is_array($serverRequest->getParsedBody())) { + return $serverRequest; + } + + if (!isset($serverRequest->getParsedBody()['access_token'])) { + return $serverRequest; + } + + return $serverRequest->withHeader('Authorization', sprintf('Bearer %s', (string) $serverRequest->getParsedBody()['access_token'])); + } +} diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index db931f29..18712fc2 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -20,6 +20,7 @@ public function getConfigTreeBuilder() $rootNode->append($this->createResourceServerNode()); $rootNode->append($this->createScopesNode()); $rootNode->append($this->createPersistenceNode()); + $rootNode->append($this->createOpenIDConnectNode()); return $treeBuilder; } @@ -128,6 +129,25 @@ private function createPersistenceNode(): NodeDefinition return $node; } + private function createOpenIDConnectNode(): NodeDefinition + { + $treeBuilder = $this->getWrappedTreeBuilder('openid_connect'); + $node = $treeBuilder->getRootNode(); + + $node + ->info('Adds OpenID Connect Provider capabilities.') + ->treatFalseLike(['enabled' => false]) + ->treatTrueLike(['enabled' => true]) + ->treatNullLike(['enabled' => false]) + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled')->defaultNull()->end() + ->end() + ; + + return $node; + } + private function getWrappedTreeBuilder(string $name): object { return new class($name) extends TreeBuilder { diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 28ec8502..67c6f674 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -35,6 +35,7 @@ public function load(array $configs, ContainerBuilder $container) $this->configureAuthorizationServer($container, $config['authorization_server']); $this->configureResourceServer($container, $config['resource_server']); $this->configureScopes($container, $config['scopes']); + $this->configureOpenIDConnect($container, $config['openid_connect']); } /** @@ -206,4 +207,14 @@ private function configureScopes(ContainerBuilder $container, array $scopes): vo ]); } } + + private function configureOpenIDConnect(ContainerBuilder $container, array $openid_connect): void + { + if (isset($openid_connect['enabled']) && $openid_connect['enabled']) { + $container + ->getDefinition('league.oauth2.server.authorization_server') + ->setArgument(5, new Reference('openid_connect_server.id_token_response')) + ; + } + } } diff --git a/Event/ClaimsResolveEvent.php b/Event/ClaimsResolveEvent.php new file mode 100644 index 00000000..32effb4a --- /dev/null +++ b/Event/ClaimsResolveEvent.php @@ -0,0 +1,43 @@ +identifier = $identifier; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + public function getClaims(): array + { + return $this->claims; + } + + /** + * @param string[] $claims + */ + public function setClaims(array $claims): self + { + $this->claims = $claims; + + return $this; + } +} diff --git a/OAuth2Events.php b/OAuth2Events.php index aa31cd8e..7f81e01b 100644 --- a/OAuth2Events.php +++ b/OAuth2Events.php @@ -28,4 +28,12 @@ final class OAuth2Events * the user should decide about it. */ public const AUTHORIZATION_REQUEST_RESOLVE = 'trikoder.oauth2.authorization_request_resolve'; + + /** + * The AUTHORIZATION_CLAIMS_RESOLVE event occurrs when the user requests + * an id token from the OpenID Connect Provider + * + * You should set the user claims here if applicable. + */ + public const AUTHORIZATION_CLAIMS_RESOLVE = 'trikoder.oauth2.claims_resolve'; } diff --git a/OpenIDConnect/Entity/Identity.php b/OpenIDConnect/Entity/Identity.php new file mode 100644 index 00000000..b3211c84 --- /dev/null +++ b/OpenIDConnect/Entity/Identity.php @@ -0,0 +1,24 @@ +claims; + } + + public function setClaims(array $claims) + { + $this->claims = $claims; + } +} diff --git a/OpenIDConnect/Repository/IdentityProvider.php b/OpenIDConnect/Repository/IdentityProvider.php new file mode 100644 index 00000000..35f66658 --- /dev/null +++ b/OpenIDConnect/Repository/IdentityProvider.php @@ -0,0 +1,37 @@ +eventDispatcher = $eventDispatcher; + } + + public function getUserEntityByIdentifier($identifier) + { + $user = new Identity(); + $user->setIdentifier($identifier); + + $event = $this->eventDispatcher->dispatch( + OAuth2Events::AUTHORIZATION_CLAIMS_RESOLVE, + new ClaimsResolveEvent($identifier) + ); + + $user->setClaims($event->getClaims()); + + return $user; + } +} diff --git a/Resources/config/routes.xml b/Resources/config/routes.xml index fc7df6c8..f19c67ca 100644 --- a/Resources/config/routes.xml +++ b/Resources/config/routes.xml @@ -5,4 +5,5 @@ + diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 6d1c7379..da37b519 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -92,6 +92,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/Tests/Acceptance/TokenEndpointTest.php b/Tests/Acceptance/TokenEndpointTest.php index 48aa86d5..190a1cd1 100644 --- a/Tests/Acceptance/TokenEndpointTest.php +++ b/Tests/Acceptance/TokenEndpointTest.php @@ -131,6 +131,38 @@ public function testSuccessfulAuthorizationCodeRequest() $this->assertNotEmpty($jsonResponse['access_token']); } + public function testSuccessfulAuthorizationCodeOpenIDRequest() + { + $authCode = $this->client + ->getContainer() + ->get(AuthCodeManagerInterface::class) + ->find(FixtureFactory::FIXTURE_AUTH_CODE_OPENID); + + timecop_freeze(new DateTime()); + + $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), + ]); + + timecop_return(); + + $response = $this->client->getResponse(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json; charset=UTF-8', $response->headers->get('Content-Type')); + + $jsonResponse = json_decode($response->getContent(), true); + + $this->assertSame('Bearer', $jsonResponse['token_type']); + $this->assertSame(3600, $jsonResponse['expires_in']); + $this->assertNotEmpty($jsonResponse['access_token']); + $this->assertNotEmpty($jsonResponse['id_token']); + } + public function testFailedTokenRequest() { $this->client->request('GET', '/token'); diff --git a/Tests/Acceptance/UserInfoEndpointTest.php b/Tests/Acceptance/UserInfoEndpointTest.php new file mode 100644 index 00000000..340b877e --- /dev/null +++ b/Tests/Acceptance/UserInfoEndpointTest.php @@ -0,0 +1,74 @@ +client->getContainer()->get(AccessTokenManagerInterface::class) + ->find(FixtureFactory::FIXTURE_ACCESS_TOKEN_USER_BOUND); + + $this->client->request('GET', '/userinfo', [], [], [ + 'HTTP_AUTHORIZATION' => TestHelper::generateJwtToken($accessToken), + ]); + + $response = $this->client->getResponse(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringStartsWith('application/json', $response->headers->get('Content-Type')); + + $jsonResponse = json_decode($response->getContent(), true); + $this->assertEquals('user', $jsonResponse['sub']); + } + + public function testSuccessfulPostUserInfoRequest() + { + $accessToken = $this->client->getContainer()->get(AccessTokenManagerInterface::class) + ->find(FixtureFactory::FIXTURE_ACCESS_TOKEN_USER_BOUND); + + $this->client->request('POST', '/userinfo', [ + 'access_token' => TestHelper::generateJwtToken($accessToken), + ]); + + $response = $this->client->getResponse(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringStartsWith('application/json', $response->headers->get('Content-Type')); + + $jsonResponse = json_decode($response->getContent(), true); + $this->assertEquals('user', $jsonResponse['sub']); + } + + public function testUnauthorizedGetUserInfoRequest() + { + $accessToken = $this->client->getContainer()->get(AccessTokenManagerInterface::class) + ->find(FixtureFactory::FIXTURE_ACCESS_TOKEN_EXPIRED); + + $this->client->request('GET', '/userinfo', [], [], [ + 'HTTP_AUTHORIZATION' => TestHelper::generateJwtToken($accessToken), + ]); + + $response = $this->client->getResponse(); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testUnauthorizedPostUserInfoRequest() + { + $accessToken = $this->client->getContainer()->get(AccessTokenManagerInterface::class) + ->find(FixtureFactory::FIXTURE_ACCESS_TOKEN_REVOKED); + + $this->client->request('POST', '/userinfo', [ + 'access_token' => TestHelper::generateJwtToken($accessToken), + ]); + + $response = $this->client->getResponse(); + + $this->assertSame(401, $response->getStatusCode()); + } +} diff --git a/Tests/Fixtures/FixtureFactory.php b/Tests/Fixtures/FixtureFactory.php index 1a8092bd..84219904 100644 --- a/Tests/Fixtures/FixtureFactory.php +++ b/Tests/Fixtures/FixtureFactory.php @@ -34,6 +34,7 @@ final class FixtureFactory public const FIXTURE_AUTH_CODE = '0aa70e8152259988b3c8e9e8cff604019bb986eb226bd126da189829b95a2be631e2506042064e12'; public const FIXTURE_AUTH_CODE_DIFFERENT_CLIENT = 'e8fe264053cb346f4437af05c8cc9036931cfec3a0d5b54bdae349304ca4a83fd2f4590afd51e559'; public const FIXTURE_AUTH_CODE_EXPIRED = 'a7bdbeb26c9f095d842f5e5b8e313b24318d6b26728d1c543136727bbe9525f7a7930305a09b7401'; + public const FIXTURE_AUTH_CODE_OPENID = '86adfc23d7b07ba70b9a501c03ff9fafb967efb2b4b14099feced03a14430c3c00a69b3282f769a8'; public const FIXTURE_CLIENT_FIRST = 'foo'; public const FIXTURE_CLIENT_SECOND = 'bar'; @@ -44,6 +45,7 @@ final class FixtureFactory public const FIXTURE_SCOPE_FIRST = 'fancy'; public const FIXTURE_SCOPE_SECOND = 'rock'; + public const FIXTURE_SCOPE_OPENID = 'openid'; public const FIXTURE_USER = 'user'; public const FIXTURE_PASSWORD = 'pass'; @@ -209,6 +211,14 @@ public static function createAuthCodes(ClientManagerInterface $clientManager): a [] ); + $authCodes[] = new AuthCode( + self::FIXTURE_AUTH_CODE_OPENID, + new DateTime('+2 minute'), + $clientManager->find(self::FIXTURE_CLIENT_FIRST), + self::FIXTURE_USER, + [new Scope(self::FIXTURE_SCOPE_OPENID)] + ); + return $authCodes; } @@ -241,6 +251,7 @@ public static function createScopes(): array $scopes = []; $scopes[] = new Scope(self::FIXTURE_SCOPE_FIRST); + $scopes[] = new Scope(self::FIXTURE_SCOPE_OPENID); return $scopes; } diff --git a/Tests/Integration/AbstractIntegrationTest.php b/Tests/Integration/AbstractIntegrationTest.php index 1d85a6e6..c68c7d61 100644 --- a/Tests/Integration/AbstractIntegrationTest.php +++ b/Tests/Integration/AbstractIntegrationTest.php @@ -5,6 +5,8 @@ use DateInterval; use Defuse\Crypto\Crypto; use Defuse\Crypto\Exception\CryptoException; +use Lcobucci\JWT\Parser; +use Lcobucci\JWT\Token; use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\CryptKey; use League\OAuth2\Server\Exception\OAuthServerException; @@ -19,6 +21,9 @@ use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; use League\OAuth2\Server\Repositories\UserRepositoryInterface; use League\OAuth2\Server\ResourceServer; +use OpenIDConnectServer\ClaimExtractor; +use OpenIDConnectServer\IdTokenResponse; +use OpenIDConnectServer\Repositories\IdentityProviderInterface; use Psr\Http\Message\ServerRequestInterface; use Symfony\Bundle\FrameworkBundle\Tests\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -43,6 +48,7 @@ use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken; +use Trikoder\Bundle\OAuth2Bundle\OpenIDConnect\Repository\IdentityProvider; use Trikoder\Bundle\OAuth2Bundle\Tests\Fixtures\FixtureFactory; use Trikoder\Bundle\OAuth2Bundle\Tests\TestHelper; use Zend\Diactoros\Response; @@ -117,6 +123,7 @@ protected function setUp() $refreshTokenRepository = new RefreshTokenRepository($this->refreshTokenManager, $this->accessTokenManager); $userRepository = new UserRepository($this->clientManager, $this->eventDispatcher); $authCodeRepository = new AuthCodeRepository($this->authCodeManager, $this->clientManager, $scopeConverter); + $identityRepository = new IdentityProvider($this->eventDispatcher); $this->authorizationServer = $this->createAuthorizationServer( $scopeRepository, @@ -124,7 +131,8 @@ protected function setUp() $accessTokenRepository, $refreshTokenRepository, $userRepository, - $authCodeRepository + $authCodeRepository, + $identityRepository ); $this->resourceServer = $this->createResourceServer($accessTokenRepository); @@ -160,6 +168,11 @@ protected function getRefreshToken(string $encryptedPayload): ?RefreshToken ); } + protected function getIdToken(string $jwtToken): Token + { + return (new Parser())->parse($jwtToken); + } + protected function createAuthorizationRequest(?string $credentials, array $body = []): ServerRequestInterface { $headers = [ @@ -243,14 +256,16 @@ private function createAuthorizationServer( AccessTokenRepositoryInterface $accessTokenRepository, RefreshTokenRepositoryInterface $refreshTokenRepository, UserRepositoryInterface $userRepository, - AuthCodeRepositoryInterface $authCodeRepository + AuthCodeRepositoryInterface $authCodeRepository, + IdentityProviderInterface $identityRepository ): AuthorizationServer { $authorizationServer = new AuthorizationServer( $clientRepository, $accessTokenRepository, $scopeRepository, new CryptKey(TestHelper::PRIVATE_KEY_PATH, null, false), - TestHelper::ENCRYPTION_KEY + TestHelper::ENCRYPTION_KEY, + new IdTokenResponse($identityRepository, new ClaimExtractor()) ); $authorizationServer->enableGrantType(new ClientCredentialsGrant()); diff --git a/Tests/Integration/OpenIDProviderTest.php b/Tests/Integration/OpenIDProviderTest.php new file mode 100644 index 00000000..5f19941b --- /dev/null +++ b/Tests/Integration/OpenIDProviderTest.php @@ -0,0 +1,35 @@ +authCodeManager->find(FixtureFactory::FIXTURE_AUTH_CODE_OPENID); + + $request = $this->createAuthorizationRequest('foo:secret', [ + 'grant_type' => 'authorization_code', + 'code' => TestHelper::generateEncryptedAuthCodePayload($openIdAuthCode), + 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', + ]); + + timecop_freeze(new DateTime()); + $response = $this->handleAuthorizationRequest($request); + $issuedAtTimestamp = time(); + $expirationTimestamp = strtotime('+3600 sec'); + timecop_return(); + + $this->assertArrayHasKey('id_token', $response); + $idToken = $this->getIdToken($response['id_token']); + $this->assertSame('http://', $idToken->getClaim('iss')); + $this->assertSame('user', $idToken->getClaim('sub')); + $this->assertSame('foo', $idToken->getClaim('aud')); + $this->assertEquals($expirationTimestamp, $idToken->getClaim('exp')); + $this->assertEquals($issuedAtTimestamp, $idToken->getClaim('iat')); + } +} diff --git a/Tests/TestHelper.php b/Tests/TestHelper.php index 5724c11a..eb41c2f0 100644 --- a/Tests/TestHelper.php +++ b/Tests/TestHelper.php @@ -46,7 +46,7 @@ public static function generateEncryptedAuthCodePayload(AuthCodeModel $authCode) 'client_id' => $authCode->getClient()->getIdentifier(), 'redirect_uri' => (string) $authCode->getClient()->getRedirectUris()[0], 'auth_code_id' => $authCode->getIdentifier(), - 'scopes' => (new ScopeConverter())->toDomainArray($authCode->getScopes()), + 'scopes' => (new ScopeConverter())->toLeagueArray($authCode->getScopes()), 'user_id' => $authCode->getUserIdentifier(), 'expire_time' => $authCode->getExpiryDateTime()->getTimestamp(), 'code_challenge' => null, diff --git a/Tests/TestKernel.php b/Tests/TestKernel.php index c26fbc41..1747b860 100644 --- a/Tests/TestKernel.php +++ b/Tests/TestKernel.php @@ -140,6 +140,9 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa 'entity_manager' => 'default', ], ], + 'openid_connect' => [ + 'enabled' => true, + ], ]); $container diff --git a/composer.json b/composer.json index 79ad29eb..cc98a2de 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "symfony/framework-bundle": "~3.4|~4.0", "symfony/psr-http-message-bridge": "^1.0", "symfony/security-bundle": "~3.4|~4.0", - "zendframework/zend-diactoros": "^1.7" + "zendframework/zend-diactoros": "^1.7", + "steverhoades/oauth2-openid-connect-server": "^1.0" }, "require-dev": { "ext-timecop": "*", @@ -41,6 +42,7 @@ "test": "phpunit" }, "suggest": { - "nelmio/cors-bundle": "For handling CORS requests" + "nelmio/cors-bundle": "For handling CORS requests", + "steverhoades/oauth2-openid-connect-server": "For OpenID Connect Provider capabilities" } } From 84a2a5a3bf4b1fa93987e999be643cdfbc5cb1b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Thu, 7 Feb 2019 11:18:18 +0100 Subject: [PATCH 10/44] Move the authorization check into an EventListener --- Controller/AuthorizationController.php | 29 ++++----------- DependencyInjection/Configuration.php | 18 +++++++++ .../TrikoderOAuth2Extension.php | 9 +++++ .../CheckRequiredAuthorizationListener.php | 37 +++++++++++++++++++ Resources/config/services.xml | 8 +++- Tests/TestKernel.php | 1 + 6 files changed, 80 insertions(+), 22 deletions(-) create mode 100644 EventListener/CheckRequiredAuthorizationListener.php diff --git a/Controller/AuthorizationController.php b/Controller/AuthorizationController.php index ede08d20..41faac31 100644 --- a/Controller/AuthorizationController.php +++ b/Controller/AuthorizationController.php @@ -4,12 +4,11 @@ use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\Exception\OAuthServerException; -use LogicException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\User\UserInterface; use Trikoder\Bundle\OAuth2Bundle\Event\AuthorizationRequestResolveEvent; use Trikoder\Bundle\OAuth2Bundle\League\Entity\User; @@ -23,11 +22,6 @@ final class AuthorizationController */ private $server; - /** - * @var AuthorizationCheckerInterface - */ - private $authorizationChecker; - /** * @var TokenStorageInterface */ @@ -38,20 +32,15 @@ final class AuthorizationController */ private $eventDispatcher; - public function __construct(AuthorizationServer $server, AuthorizationCheckerInterface $authorizationChecker, TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher) + public function __construct(AuthorizationServer $server, TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher) { $this->server = $server; - $this->authorizationChecker = $authorizationChecker; $this->tokenStorage = $tokenStorage; $this->eventDispatcher = $eventDispatcher; } public function indexAction(ServerRequestInterface $serverRequest): ResponseInterface { - if (!$this->authorizationChecker->isGranted('IS_AUTHENTICATED_REMEMBERED')) { - throw new LogicException('There is no logged in user. Review your security config to protect this endpoint.'); - } - $serverResponse = new Response(); try { @@ -79,17 +68,15 @@ public function indexAction(ServerRequestInterface $serverRequest): ResponseInte private function getUserEntity(): User { + $userEntity = new User(); + $token = $this->tokenStorage->getToken(); - if (null === $token) { - throw new LogicException('There is no security token available. Review your security config to protect endpoint.'); + if ($token instanceof TokenInterface) { + $user = $token->getUser(); + $username = $user instanceof UserInterface ? $user->getUsername() : (string) $user; + $userEntity->setIdentifier($username); } - $user = $token->getUser(); - $username = $user instanceof UserInterface ? $user->getUsername() : (string) $user; - - $userEntity = new User(); - $userEntity->setIdentifier($username); - return $userEntity; } } diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index db931f29..5f91a400 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -20,6 +20,7 @@ public function getConfigTreeBuilder() $rootNode->append($this->createResourceServerNode()); $rootNode->append($this->createScopesNode()); $rootNode->append($this->createPersistenceNode()); + $rootNode->append($this->createAuthorizationEndpointNode()); return $treeBuilder; } @@ -128,6 +129,23 @@ private function createPersistenceNode(): NodeDefinition return $node; } + private function createAuthorizationEndpointNode(): NodeDefinition + { + $treeBuilder = $this->getWrappedTreeBuilder('authorization_endpoint'); + $node = $treeBuilder->getRootNode(); + + $node + ->children() + ->variableNode('required_attributes') + ->info('Required attributes to proccess authorization request') + ->defaultValue('IS_AUTHENTICATED_REMEMBERED') + ->end() + ->end() + ; + + return $node; + } + private function getWrappedTreeBuilder(string $name): object { return new class($name) extends TreeBuilder { diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 28ec8502..5eda8231 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -35,6 +35,7 @@ public function load(array $configs, ContainerBuilder $container) $this->configureAuthorizationServer($container, $config['authorization_server']); $this->configureResourceServer($container, $config['resource_server']); $this->configureScopes($container, $config['scopes']); + $this->configureAuthorizationEndpoint($container, $config['authorization_endpoint']); } /** @@ -206,4 +207,12 @@ private function configureScopes(ContainerBuilder $container, array $scopes): vo ]); } } + + private function configureAuthorizationEndpoint(ContainerBuilder $container, array $config): void + { + $container + ->getDefinition('trikoder.oauth2.event_listener.check_required_authorization_listener') + ->replaceArgument('$requiredAttributes', $config['required_attributes']) + ; + } } diff --git a/EventListener/CheckRequiredAuthorizationListener.php b/EventListener/CheckRequiredAuthorizationListener.php new file mode 100644 index 00000000..e17b5256 --- /dev/null +++ b/EventListener/CheckRequiredAuthorizationListener.php @@ -0,0 +1,37 @@ +authorizationChecker = $authorizationChecker; + $this->requiredAttributes = $requiredAttributes; + } + + public function onAuthorizationRequestResolveEvent(AuthorizationRequestResolveEvent $event): void + { + if (null === $this->requiredAttributes) { + return; + } + + if (!$this->authorizationChecker->isGranted($this->requiredAttributes)) { + throw new LogicException(sprintf('The current authorization token does not grant required attributes "%s". Review your security configuration.', json_encode($this->requiredAttributes))); + } + } +} diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 6d1c7379..09b80cfb 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -80,7 +80,6 @@ - @@ -92,6 +91,13 @@ + + + + + + + diff --git a/Tests/TestKernel.php b/Tests/TestKernel.php index c26fbc41..6b10bedc 100644 --- a/Tests/TestKernel.php +++ b/Tests/TestKernel.php @@ -140,6 +140,7 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa 'entity_manager' => 'default', ], ], + 'authorization_endpoint' => null, ]); $container From c2ca3f5a7a5637d32c15a380eb21c578119f6e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Thu, 7 Feb 2019 11:28:42 +0100 Subject: [PATCH 11/44] Remove authorization check --- DependencyInjection/Configuration.php | 18 --------- .../TrikoderOAuth2Extension.php | 9 ----- .../CheckRequiredAuthorizationListener.php | 37 ------------------- README.md | 11 +++++- Resources/config/services.xml | 7 ---- .../Acceptance/AuthorizationEndpointTest.php | 18 +-------- Tests/Fixtures/FixtureFactory.php | 1 - Tests/TestKernel.php | 11 ------ 8 files changed, 11 insertions(+), 101 deletions(-) delete mode 100644 EventListener/CheckRequiredAuthorizationListener.php diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 5f91a400..db931f29 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -20,7 +20,6 @@ public function getConfigTreeBuilder() $rootNode->append($this->createResourceServerNode()); $rootNode->append($this->createScopesNode()); $rootNode->append($this->createPersistenceNode()); - $rootNode->append($this->createAuthorizationEndpointNode()); return $treeBuilder; } @@ -129,23 +128,6 @@ private function createPersistenceNode(): NodeDefinition return $node; } - private function createAuthorizationEndpointNode(): NodeDefinition - { - $treeBuilder = $this->getWrappedTreeBuilder('authorization_endpoint'); - $node = $treeBuilder->getRootNode(); - - $node - ->children() - ->variableNode('required_attributes') - ->info('Required attributes to proccess authorization request') - ->defaultValue('IS_AUTHENTICATED_REMEMBERED') - ->end() - ->end() - ; - - return $node; - } - private function getWrappedTreeBuilder(string $name): object { return new class($name) extends TreeBuilder { diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 5eda8231..28ec8502 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -35,7 +35,6 @@ public function load(array $configs, ContainerBuilder $container) $this->configureAuthorizationServer($container, $config['authorization_server']); $this->configureResourceServer($container, $config['resource_server']); $this->configureScopes($container, $config['scopes']); - $this->configureAuthorizationEndpoint($container, $config['authorization_endpoint']); } /** @@ -207,12 +206,4 @@ private function configureScopes(ContainerBuilder $container, array $scopes): vo ]); } } - - private function configureAuthorizationEndpoint(ContainerBuilder $container, array $config): void - { - $container - ->getDefinition('trikoder.oauth2.event_listener.check_required_authorization_listener') - ->replaceArgument('$requiredAttributes', $config['required_attributes']) - ; - } } diff --git a/EventListener/CheckRequiredAuthorizationListener.php b/EventListener/CheckRequiredAuthorizationListener.php deleted file mode 100644 index e17b5256..00000000 --- a/EventListener/CheckRequiredAuthorizationListener.php +++ /dev/null @@ -1,37 +0,0 @@ -authorizationChecker = $authorizationChecker; - $this->requiredAttributes = $requiredAttributes; - } - - public function onAuthorizationRequestResolveEvent(AuthorizationRequestResolveEvent $event): void - { - if (null === $this->requiredAttributes) { - return; - } - - if (!$this->authorizationChecker->isGranted($this->requiredAttributes)) { - throw new LogicException(sprintf('The current authorization token does not grant required attributes "%s". Review your security configuration.', json_encode($this->requiredAttributes))); - } - } -} diff --git a/README.md b/README.md index 9f0f6f97..8f3936df 100644 --- a/README.md +++ b/README.md @@ -100,10 +100,19 @@ This package is currently in the active development. ```yaml oauth2: resource: '@TrikoderOAuth2Bundle/Resources/config/routes.xml' - ``` + ``` You can verify that everything is working by issuing a `GET` request to the `/token` endpoint. +**❮ NOTE ❯** It is recommended to control the access to the authorization endpoint +so that only logged in users can approve authorization requests. +You should review your `security.yml` file. Here is a sample configuration: + + ```yaml + access_control: + - { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED } + ``` + ## Configuration * [Basic setup](docs/basic-setup.md) diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 09b80cfb..b32d818e 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -91,13 +91,6 @@ - - - - - - - diff --git a/Tests/Acceptance/AuthorizationEndpointTest.php b/Tests/Acceptance/AuthorizationEndpointTest.php index f4a1c73d..8b697a3a 100644 --- a/Tests/Acceptance/AuthorizationEndpointTest.php +++ b/Tests/Acceptance/AuthorizationEndpointTest.php @@ -27,11 +27,6 @@ public function testSuccessfulCodeRequest() 'client_id' => FixtureFactory::FIXTURE_CLIENT_FIRST, 'response_type' => 'code', 'state' => 'foobar', - ], - [], - [ - 'PHP_AUTH_USER' => FixtureFactory::FIXTURE_USER, - 'PHP_AUTH_PW' => FixtureFactory::FIXTURE_PASSWORD, ] ); @@ -70,11 +65,6 @@ public function testCodeRequestRedirectToDecision() 'state' => 'foobar', 'redirect_uri' => FixtureFactory::FIXTURE_CLIENT_FIRST_REDIRECT_URI, 'scope' => FixtureFactory::FIXTURE_SCOPE_FIRST . ' ' . FixtureFactory::FIXTURE_SCOPE_SECOND, - ], - [], - [ - 'PHP_AUTH_USER' => FixtureFactory::FIXTURE_USER, - 'PHP_AUTH_PW' => FixtureFactory::FIXTURE_PASSWORD, ] ); @@ -91,13 +81,7 @@ public function testFailedAuthorizeRequest() { $this->client->request( 'GET', - '/authorize', - [], - [], - [ - 'PHP_AUTH_USER' => FixtureFactory::FIXTURE_USER, - 'PHP_AUTH_PW' => FixtureFactory::FIXTURE_PASSWORD, - ] + '/authorize' ); $response = $this->client->getResponse(); diff --git a/Tests/Fixtures/FixtureFactory.php b/Tests/Fixtures/FixtureFactory.php index 1a8092bd..59c67aa5 100644 --- a/Tests/Fixtures/FixtureFactory.php +++ b/Tests/Fixtures/FixtureFactory.php @@ -46,7 +46,6 @@ final class FixtureFactory public const FIXTURE_SCOPE_SECOND = 'rock'; public const FIXTURE_USER = 'user'; - public const FIXTURE_PASSWORD = 'pass'; public static function initializeFixtures( ScopeManagerInterface $scopeManager, diff --git a/Tests/TestKernel.php b/Tests/TestKernel.php index 6b10bedc..b3dbb4b4 100644 --- a/Tests/TestKernel.php +++ b/Tests/TestKernel.php @@ -8,7 +8,6 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Routing\RouteCollectionBuilder; -use Symfony\Component\Security\Core\User\UserInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\AuthCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; @@ -90,11 +89,6 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa $container->loadFromExtension('security', [ 'firewalls' => [ - 'auth' => [ - 'pattern' => '^/authorize', - 'stateless' => true, - 'http_basic' => true, - ], 'test' => [ 'pattern' => '^/security-test', 'stateless' => true, @@ -106,16 +100,12 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa 'memory' => [ 'users' => [ FixtureFactory::FIXTURE_USER => [ - 'password' => FixtureFactory::FIXTURE_PASSWORD, 'roles' => ['ROLE_USER'], ], ], ], ], ], - 'encoders' => [ - UserInterface::class => 'plaintext', - ], ]); $container->loadFromExtension('sensio_framework_extra', [ @@ -140,7 +130,6 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa 'entity_manager' => 'default', ], ], - 'authorization_endpoint' => null, ]); $container From 2e4c9c78ef19877914d8bdcb63613f9723d231d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Thu, 7 Feb 2019 11:50:27 +0100 Subject: [PATCH 12/44] Fix README --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8f3936df..7102c79e 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ This package is currently in the active development. # The value should be a valid interval: http://php.net/manual/en/dateinterval.construct.php#refsect1-dateinterval.construct-parameters refresh_token_ttl: P1M + # How long the issued auth code should be valid for. + # The value should be a valid interval: http://php.net/manual/en/dateinterval.construct.php#refsect1-dateinterval.construct-parameters + auth_code_ttl: PT10M + resource_server: # Full path to the public key file @@ -108,10 +112,11 @@ You can verify that everything is working by issuing a `GET` request to the `/to so that only logged in users can approve authorization requests. You should review your `security.yml` file. Here is a sample configuration: - ```yaml +```yaml +security: access_control: - { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED } - ``` +``` ## Configuration From 345c0b6adf70a6e44f239682bce50fb4be9527fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Fri, 8 Feb 2019 11:06:59 +0100 Subject: [PATCH 13/44] Rename `decisionUri` to `resolutionUri` This Uri can represent any URI, not only the decision (consent) uri. --- Controller/AuthorizationController.php | 2 +- Event/AuthorizationRequestResolveEvent.php | 14 +++++++------- OAuth2Events.php | 2 +- Tests/Acceptance/AuthorizationEndpointTest.php | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Controller/AuthorizationController.php b/Controller/AuthorizationController.php index 41faac31..0b0678b3 100644 --- a/Controller/AuthorizationController.php +++ b/Controller/AuthorizationController.php @@ -53,7 +53,7 @@ public function indexAction(ServerRequestInterface $serverRequest): ResponseInte ); if (AuthorizationRequestResolveEvent::AUTHORIZATION_PENDING === $event->getAuhorizationResolution()) { - return $serverResponse->withStatus(302)->withHeader('Location', $event->getDecisionUri()); + return $serverResponse->withStatus(302)->withHeader('Location', $event->getResolutionUri()); } if (AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED === $event->getAuhorizationResolution()) { diff --git a/Event/AuthorizationRequestResolveEvent.php b/Event/AuthorizationRequestResolveEvent.php index b2c4b77d..907a8292 100644 --- a/Event/AuthorizationRequestResolveEvent.php +++ b/Event/AuthorizationRequestResolveEvent.php @@ -20,7 +20,7 @@ final class AuthorizationRequestResolveEvent extends Event /** * @var ?string */ - private $decisionUri; + private $resolutionUri; /** * @var ?bool @@ -45,18 +45,18 @@ public function resolveAuthorization(bool $authorizationResolution) $this->authorizationResolution = $authorizationResolution; } - public function getDecisionUri(): string + public function getResolutionUri(): string { - if (null === $this->decisionUri) { - throw new LogicException('There is no decision URI. If the authorization request is not approved nor denied, a decision URI should be provided'); + if (null === $this->resolutionUri) { + throw new LogicException('There is no resolution URI. If the authorization request is not approved nor denied, a resolution URI should be provided'); } - return $this->decisionUri; + return $this->resolutionUri; } - public function setDecisionUri(string $decisionUri) + public function setResolutionUri(string $resolutionUri) { - $this->decisionUri = $decisionUri; + $this->resolutionUri = $resolutionUri; } /** diff --git a/OAuth2Events.php b/OAuth2Events.php index aa31cd8e..8ef1378c 100644 --- a/OAuth2Events.php +++ b/OAuth2Events.php @@ -25,7 +25,7 @@ final class OAuth2Events * complete the authorization request. * * You could approve or deny the authorization request, or set the uri where - * the user should decide about it. + * must be redirected to resolve the authorization request. */ public const AUTHORIZATION_REQUEST_RESOLVE = 'trikoder.oauth2.authorization_request_resolve'; } diff --git a/Tests/Acceptance/AuthorizationEndpointTest.php b/Tests/Acceptance/AuthorizationEndpointTest.php index 8b697a3a..a8ef0b9e 100644 --- a/Tests/Acceptance/AuthorizationEndpointTest.php +++ b/Tests/Acceptance/AuthorizationEndpointTest.php @@ -45,13 +45,13 @@ public function testSuccessfulCodeRequest() $this->assertEquals('foobar', $query['state']); } - public function testCodeRequestRedirectToDecision() + public function testCodeRequestRedirectToResolutionUri() { $this->client ->getContainer() ->get('event_dispatcher') ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event) { - $event->setDecisionUri('/authorize/decision'); + $event->setResolutionUri('/authorize/consent'); }); timecop_freeze(new DateTime()); @@ -74,7 +74,7 @@ public function testCodeRequestRedirectToDecision() $this->assertSame(302, $response->getStatusCode()); $redirectUri = $response->headers->get('Location'); - $this->assertEquals('/authorize/decision', $redirectUri); + $this->assertEquals('/authorize/consent', $redirectUri); } public function testFailedAuthorizeRequest() From a8dd5567dad50f6350212615312aff1f8051b4c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Mon, 11 Feb 2019 10:53:53 +0100 Subject: [PATCH 14/44] Add return types to AuthorizationRequestResolveEvent --- Event/AuthorizationRequestResolveEvent.php | 47 ++++++---------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/Event/AuthorizationRequestResolveEvent.php b/Event/AuthorizationRequestResolveEvent.php index 907a8292..b047c478 100644 --- a/Event/AuthorizationRequestResolveEvent.php +++ b/Event/AuthorizationRequestResolveEvent.php @@ -2,6 +2,9 @@ namespace Trikoder\Bundle\OAuth2Bundle\Event; +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Entities\ScopeEntityInterface; +use League\OAuth2\Server\Entities\UserEntityInterface; use League\OAuth2\Server\RequestTypes\AuthorizationRequest; use Symfony\Component\EventDispatcher\Event; use Symfony\Component\Security\Core\Exception\LogicException; @@ -54,31 +57,22 @@ public function getResolutionUri(): string return $this->resolutionUri; } - public function setResolutionUri(string $resolutionUri) + public function setResolutionUri(string $resolutionUri): void { $this->resolutionUri = $resolutionUri; } - /** - * @return string - */ - public function getGrantTypeId() + public function getGrantTypeId(): string { return $this->authorizationRequest->getGrantTypeId(); } - /** - * @return ClientEntityInterface - */ - public function getClient() + public function getClient(): ClientEntityInterface { return $this->authorizationRequest->getClient(); } - /** - * @return UserEntityInterface - */ - public function getUser() + public function getUser(): UserEntityInterface { return $this->authorizationRequest->getUser(); } @@ -86,47 +80,32 @@ public function getUser() /** * @return ScopeEntityInterface[] */ - public function getScopes() + public function getScopes(): array { return $this->authorizationRequest->getScopes(); } - /** - * @return bool - */ - public function isAuthorizationApproved() + public function isAuthorizationApproved(): bool { return $this->authorizationRequest->isAuthorizationApproved(); } - /** - * @return string|null - */ - public function getRedirectUri() + public function getRedirectUri(): ?string { return $this->authorizationRequest->getRedirectUri(); } - /** - * @return string|null - */ - public function getState() + public function getState(): ?string { return $this->authorizationRequest->getState(); } - /** - * @return string - */ - public function getCodeChallenge() + public function getCodeChallenge(): string { return $this->authorizationRequest->getCodeChallenge(); } - /** - * @return string - */ - public function getCodeChallengeMethod() + public function getCodeChallengeMethod(): string { return $this->authorizationRequest->getCodeChallengeMethod(); } From cc4a7802b5d50788c6732e4a95184e5993e110e7 Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Tue, 12 Feb 2019 16:39:33 +0100 Subject: [PATCH 15/44] refactored authoriztion controller to independent event listeners --- Controller/AuthorizationController.php | 39 +--- Event/AuthorizationRequestResolveEvent.php | 88 +++++---- Event/Listener/AuthorizationEventListener.php | 21 +++ ...orizationRequestAuthenticationListener.php | 88 +++++++++ .../AuthorizationRequestDecisionListener.php | 41 +++++ ...horizationRequestUserResolvingListener.php | 41 +++++ .../AlwaysAllowDecisionStrategy.php | 13 ++ .../AuthorizationDecisionStrategy.php | 9 + .../UserConsentDecisionStrategy.php | 169 ++++++++++++++++++ OAuth2Events.php | 8 +- Resources/config/services.xml | 28 ++- 11 files changed, 468 insertions(+), 77 deletions(-) create mode 100644 Event/Listener/AuthorizationEventListener.php create mode 100644 Event/Listener/AuthorizationRequestAuthenticationListener.php create mode 100644 Event/Listener/AuthorizationRequestDecisionListener.php create mode 100644 Event/Listener/AuthorizationRequestUserResolvingListener.php create mode 100644 Model/AuthorizationDecision/AlwaysAllowDecisionStrategy.php create mode 100644 Model/AuthorizationDecision/AuthorizationDecisionStrategy.php create mode 100644 Model/AuthorizationDecision/UserConsentDecisionStrategy.php diff --git a/Controller/AuthorizationController.php b/Controller/AuthorizationController.php index 0b0678b3..9e0ac990 100644 --- a/Controller/AuthorizationController.php +++ b/Controller/AuthorizationController.php @@ -7,11 +7,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\User\UserInterface; use Trikoder\Bundle\OAuth2Bundle\Event\AuthorizationRequestResolveEvent; -use Trikoder\Bundle\OAuth2Bundle\League\Entity\User; use Trikoder\Bundle\OAuth2Bundle\OAuth2Events; use Zend\Diactoros\Response; @@ -22,20 +18,16 @@ final class AuthorizationController */ private $server; - /** - * @var TokenStorageInterface - */ - private $tokenStorage; - /** * @var EventDispatcherInterface */ private $eventDispatcher; - public function __construct(AuthorizationServer $server, TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher) - { + public function __construct( + AuthorizationServer $server, + EventDispatcherInterface $eventDispatcher + ) { $this->server = $server; - $this->tokenStorage = $tokenStorage; $this->eventDispatcher = $eventDispatcher; } @@ -45,19 +37,14 @@ public function indexAction(ServerRequestInterface $serverRequest): ResponseInte try { $authRequest = $this->server->validateAuthorizationRequest($serverRequest); - $authRequest->setUser($this->getUserEntity()); $event = $this->eventDispatcher->dispatch( OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, new AuthorizationRequestResolveEvent($authRequest) ); - if (AuthorizationRequestResolveEvent::AUTHORIZATION_PENDING === $event->getAuhorizationResolution()) { - return $serverResponse->withStatus(302)->withHeader('Location', $event->getResolutionUri()); - } - - if (AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED === $event->getAuhorizationResolution()) { - $authRequest->setAuthorizationApproved(true); + if ($event->hasResponse()) { + return $event->getResponse(); } return $this->server->completeAuthorizationRequest($authRequest, $serverResponse); @@ -65,18 +52,4 @@ public function indexAction(ServerRequestInterface $serverRequest): ResponseInte return $e->generateHttpResponse($serverResponse); } } - - private function getUserEntity(): User - { - $userEntity = new User(); - - $token = $this->tokenStorage->getToken(); - if ($token instanceof TokenInterface) { - $user = $token->getUser(); - $username = $user instanceof UserInterface ? $user->getUsername() : (string) $user; - $userEntity->setIdentifier($username); - } - - return $userEntity; - } } diff --git a/Event/AuthorizationRequestResolveEvent.php b/Event/AuthorizationRequestResolveEvent.php index 907a8292..b64448b1 100644 --- a/Event/AuthorizationRequestResolveEvent.php +++ b/Event/AuthorizationRequestResolveEvent.php @@ -2,63 +2,36 @@ namespace Trikoder\Bundle\OAuth2Bundle\Event; +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Entities\ScopeEntityInterface; +use League\OAuth2\Server\Entities\UserEntityInterface; use League\OAuth2\Server\RequestTypes\AuthorizationRequest; use Symfony\Component\EventDispatcher\Event; -use Symfony\Component\Security\Core\Exception\LogicException; +use Trikoder\Bundle\OAuth2Bundle\League\Entity\User; +use Zend\Diactoros\Response; +/** + * Class AuthorizationRequestResolveEvent + + * @package Trikoder\Bundle\OAuth2Bundle\Event + */ final class AuthorizationRequestResolveEvent extends Event { - public const AUTHORIZATION_APPROVED = true; - public const AUTHORIZATION_DENIED = false; - public const AUTHORIZATION_PENDING = null; - /** * @var AuthorizationRequest */ private $authorizationRequest; /** - * @var ?string - */ - private $resolutionUri; - - /** - * @var ?bool + * @var Response */ - private $authorizationResolution; + private $response; public function __construct(AuthorizationRequest $authorizationRequest) { $this->authorizationRequest = $authorizationRequest; } - /** - * @return ?bool - */ - public function getAuhorizationResolution(): ?bool - { - return $this->authorizationResolution; - } - - public function resolveAuthorization(bool $authorizationResolution) - { - $this->authorizationResolution = $authorizationResolution; - } - - public function getResolutionUri(): string - { - if (null === $this->resolutionUri) { - throw new LogicException('There is no resolution URI. If the authorization request is not approved nor denied, a resolution URI should be provided'); - } - - return $this->resolutionUri; - } - - public function setResolutionUri(string $resolutionUri) - { - $this->resolutionUri = $resolutionUri; - } - /** * @return string */ @@ -83,6 +56,11 @@ public function getUser() return $this->authorizationRequest->getUser(); } + public function setUser(User $user): void + { + $this->authorizationRequest->setUser($user); + } + /** * @return ScopeEntityInterface[] */ @@ -99,6 +77,14 @@ public function isAuthorizationApproved() return $this->authorizationRequest->isAuthorizationApproved(); } + /** + * @return void + */ + public function approveAuthorization() + { + $this->authorizationRequest->setAuthorizationApproved(true); + } + /** * @return string|null */ @@ -130,4 +116,28 @@ public function getCodeChallengeMethod() { return $this->authorizationRequest->getCodeChallengeMethod(); } + + /** + * @return Response + */ + public function getResponse(): ?Response + { + return $this->response; + } + + /** + * @param Response $response + */ + public function setResponse(Response $response): void + { + $this->response = $response; + } + + /** + * @return bool + */ + public function hasResponse(): bool + { + return $this->response !== null; + } } diff --git a/Event/Listener/AuthorizationEventListener.php b/Event/Listener/AuthorizationEventListener.php new file mode 100644 index 00000000..5f21eb99 --- /dev/null +++ b/Event/Listener/AuthorizationEventListener.php @@ -0,0 +1,21 @@ +authorizationChecker = $authorizationChecker; + $this->session = $session; + $this->requestStack = $requestStack; + $this->urlGenerator = $urlGenerator; + $this->loginRoute = $loginRoute; + } + + /** + * @param AuthorizationRequestResolveEvent $event + */ + public function onAuthorizationRequest(AuthorizationRequestResolveEvent $event): void + { + if (null === $request = $this->requestStack->getMasterRequest()) { + throw new \RuntimeException('Authentication listener depends on the request context'); + } + + if (!$this->authorizationChecker->isGranted('IS_AUTHENTICATED_REMEMBERED')) { + $this->saveTargetPath($this->session, 'main', $request->getUri()); + + $loginUrl = $this->urlGenerator->generate($this->loginRoute); + $event->setResponse(new Response(null, 302, ['Location' => $loginUrl])); + } + } +} diff --git a/Event/Listener/AuthorizationRequestDecisionListener.php b/Event/Listener/AuthorizationRequestDecisionListener.php new file mode 100644 index 00000000..7431d796 --- /dev/null +++ b/Event/Listener/AuthorizationRequestDecisionListener.php @@ -0,0 +1,41 @@ +authorizationDecisionStrategy = $authorizationDecisionStrategy; + } + + public function onAuthorizationRequest(AuthorizationRequestResolveEvent $event): void + { + // if request is already approved by other listener, there is nothing left to do + if ($event->isAuthorizationApproved()) { + return; + } + + // delegate to configured authorization strategy + $this->authorizationDecisionStrategy->decide($event); + } +} diff --git a/Event/Listener/AuthorizationRequestUserResolvingListener.php b/Event/Listener/AuthorizationRequestUserResolvingListener.php new file mode 100644 index 00000000..57dc8f8c --- /dev/null +++ b/Event/Listener/AuthorizationRequestUserResolvingListener.php @@ -0,0 +1,41 @@ +setUser($this->getUserEntity()); + } + + private function getUserEntity(): User + { + $userEntity = new User(); + + $user = $this->security->getUser(); + if ($user) { + $username = $user instanceof UserInterface ? $user->getUsername() : (string) $user; + $userEntity->setIdentifier($username); + } + + return $userEntity; + } +} diff --git a/Model/AuthorizationDecision/AlwaysAllowDecisionStrategy.php b/Model/AuthorizationDecision/AlwaysAllowDecisionStrategy.php new file mode 100644 index 00000000..3bca50da --- /dev/null +++ b/Model/AuthorizationDecision/AlwaysAllowDecisionStrategy.php @@ -0,0 +1,13 @@ +approveAuthorization(); + } +} diff --git a/Model/AuthorizationDecision/AuthorizationDecisionStrategy.php b/Model/AuthorizationDecision/AuthorizationDecisionStrategy.php new file mode 100644 index 00000000..7bd987c3 --- /dev/null +++ b/Model/AuthorizationDecision/AuthorizationDecisionStrategy.php @@ -0,0 +1,9 @@ +consentApprovalRoute = $consentApprovalRoute; + $this->uriSigner = $uriSigner; + $this->requestStack = $requestStack; + $this->urlGenerator = $urlGenerator; + } + + /** + * @param AuthorizationRequestResolveEvent $event + */ + public function decide(AuthorizationRequestResolveEvent $event): void + { + if (null === $request = $this->requestStack->getMasterRequest()) { + throw new \RuntimeException('Consent decision strategy depends on the request context'); + } + + // if the request carries approval result + if ($this->canResolveAuthorizationRequest($event, $request)) { + if ($this->isAuthorizationAllowed($request)) { + $event->approveAuthorization(); + } + + // disapproved consent is handled by League component + return; + } + + $event->setResponse($this->createRedirectToConsentResponse($event)); + } + + private function canResolveAuthorizationRequest(AuthorizationRequestResolveEvent $event, Request $request) + { + if (!$request->query->has(self::ATTRIBUTE_DECISION)) { + return false; + } + + $currentUri = $request->getRequestUri(); + if (!$this->uriSigner->check($currentUri)) { + return false; + } + + if ($request->query->get('client_id') !== $event->getClient()->getIdentifier()) { + return false; + } + if ($request->query->get('response_type') !== $this->getResponseType($event)) { + return false; + } + if ($request->query->get('redirect_uri') !== $event->getRedirectUri()) { + return false; + } + if ($request->query->get('scope') !== $this->getScope($event)) { + return false; + } + + return true; + } + + private function createRedirectToConsentResponse(AuthorizationRequestResolveEvent $event): Response + { + $params = [ + 'client_id' => $event->getClient()->getIdentifier(), + 'response_type' => $this->getResponseType($event), + ]; + if (null !== $redirectUri = $event->getRedirectUri()) { + $params['redirect_uri'] = $redirectUri; + } + if (null !== $state = $event->getState()) { + $params['state'] = $state; + } + $scope = $this->getScope($event); + if (null !== $scope) { + $params['scope'] = $scope; + } + + $redirectUri = $this->urlGenerator->generate($this->consentApprovalRoute, $params); + return new Response(null, 302, ['Location' => $redirectUri]); + } + + private function getResponseType(AuthorizationRequestResolveEvent $event): string + { + switch ($event->getGrantTypeId()) { + case OAuth2Grants::AUTHORIZATION_CODE: + return 'code'; + case OAuth2Grants::IMPLICIT: + return 'token'; + default: + return $event->getGrantTypeId(); + } + } + + private function getScope(AuthorizationRequestResolveEvent $event): ?string + { + $scopes = array_map(function (Scope $scope) { + return $scope->getIdentifier(); + }, $event->getScopes()); + + if (empty($scopes)) { + return null; + } + + return implode(' ', $scopes); + } + + private function isAuthorizationAllowed(Request $request): bool + { + return $request->get(self::ATTRIBUTE_DECISION) === self::ATTRIBUTE_DECISION_ALLOW; + } +} diff --git a/OAuth2Events.php b/OAuth2Events.php index 0ca4b54f..d7851a73 100644 --- a/OAuth2Events.php +++ b/OAuth2Events.php @@ -5,7 +5,7 @@ final class OAuth2Events { /** - * The USER_RESOLVE event occurrs when the client requests a "password" + * The USER_RESOLVE event occurs when the client requests a "password" * grant type from the authorization server. * * You should set a valid user here if applicable. @@ -13,7 +13,7 @@ final class OAuth2Events public const USER_RESOLVE = 'trikoder.oauth2.user_resolve'; /** - * The SCOPE_RESOLVE event occurrs right before the user obtains their + * The SCOPE_RESOLVE event occurs right before the user obtains their * valid access token. * * You could alter the access token's scope here. @@ -21,7 +21,7 @@ final class OAuth2Events public const SCOPE_RESOLVE = 'trikoder.oauth2.scope_resolve'; /** - * The AUTHORIZATION_REQUEST_RESOLVE event occurrs right before the system + * The AUTHORIZATION_REQUEST_RESOLVE event occurs right before the system * complete the authorization request. * * You could approve or deny the authorization request, or set the uri where @@ -30,7 +30,7 @@ final class OAuth2Events public const AUTHORIZATION_REQUEST_RESOLVE = 'trikoder.oauth2.authorization_request_resolve'; /** - * The AUTHORIZATION_CLAIMS_RESOLVE event occurrs when the user requests + * The AUTHORIZATION_CLAIMS_RESOLVE event occurs when the user requests * an id token from the OpenID Connect Provider * * You should set the user claims here if applicable. diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 8be2b7fb..8e0c4df9 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -80,11 +80,37 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + From 050fc3a67a7cffac785de6e222b202b853082da3 Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Tue, 12 Feb 2019 16:39:33 +0100 Subject: [PATCH 16/44] refactored authoriztion controller to independent event listeners # Conflicts: # OAuth2Events.php --- Controller/AuthorizationController.php | 39 +--- Event/AuthorizationRequestResolveEvent.php | 88 +++++---- Event/Listener/AuthorizationEventListener.php | 21 +++ ...orizationRequestAuthenticationListener.php | 88 +++++++++ .../AuthorizationRequestDecisionListener.php | 41 +++++ ...horizationRequestUserResolvingListener.php | 41 +++++ .../AlwaysAllowDecisionStrategy.php | 13 ++ .../AuthorizationDecisionStrategy.php | 9 + .../UserConsentDecisionStrategy.php | 169 ++++++++++++++++++ OAuth2Events.php | 6 +- Resources/config/services.xml | 28 ++- 11 files changed, 467 insertions(+), 76 deletions(-) create mode 100644 Event/Listener/AuthorizationEventListener.php create mode 100644 Event/Listener/AuthorizationRequestAuthenticationListener.php create mode 100644 Event/Listener/AuthorizationRequestDecisionListener.php create mode 100644 Event/Listener/AuthorizationRequestUserResolvingListener.php create mode 100644 Model/AuthorizationDecision/AlwaysAllowDecisionStrategy.php create mode 100644 Model/AuthorizationDecision/AuthorizationDecisionStrategy.php create mode 100644 Model/AuthorizationDecision/UserConsentDecisionStrategy.php diff --git a/Controller/AuthorizationController.php b/Controller/AuthorizationController.php index 0b0678b3..9e0ac990 100644 --- a/Controller/AuthorizationController.php +++ b/Controller/AuthorizationController.php @@ -7,11 +7,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\User\UserInterface; use Trikoder\Bundle\OAuth2Bundle\Event\AuthorizationRequestResolveEvent; -use Trikoder\Bundle\OAuth2Bundle\League\Entity\User; use Trikoder\Bundle\OAuth2Bundle\OAuth2Events; use Zend\Diactoros\Response; @@ -22,20 +18,16 @@ final class AuthorizationController */ private $server; - /** - * @var TokenStorageInterface - */ - private $tokenStorage; - /** * @var EventDispatcherInterface */ private $eventDispatcher; - public function __construct(AuthorizationServer $server, TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher) - { + public function __construct( + AuthorizationServer $server, + EventDispatcherInterface $eventDispatcher + ) { $this->server = $server; - $this->tokenStorage = $tokenStorage; $this->eventDispatcher = $eventDispatcher; } @@ -45,19 +37,14 @@ public function indexAction(ServerRequestInterface $serverRequest): ResponseInte try { $authRequest = $this->server->validateAuthorizationRequest($serverRequest); - $authRequest->setUser($this->getUserEntity()); $event = $this->eventDispatcher->dispatch( OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, new AuthorizationRequestResolveEvent($authRequest) ); - if (AuthorizationRequestResolveEvent::AUTHORIZATION_PENDING === $event->getAuhorizationResolution()) { - return $serverResponse->withStatus(302)->withHeader('Location', $event->getResolutionUri()); - } - - if (AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED === $event->getAuhorizationResolution()) { - $authRequest->setAuthorizationApproved(true); + if ($event->hasResponse()) { + return $event->getResponse(); } return $this->server->completeAuthorizationRequest($authRequest, $serverResponse); @@ -65,18 +52,4 @@ public function indexAction(ServerRequestInterface $serverRequest): ResponseInte return $e->generateHttpResponse($serverResponse); } } - - private function getUserEntity(): User - { - $userEntity = new User(); - - $token = $this->tokenStorage->getToken(); - if ($token instanceof TokenInterface) { - $user = $token->getUser(); - $username = $user instanceof UserInterface ? $user->getUsername() : (string) $user; - $userEntity->setIdentifier($username); - } - - return $userEntity; - } } diff --git a/Event/AuthorizationRequestResolveEvent.php b/Event/AuthorizationRequestResolveEvent.php index 907a8292..b64448b1 100644 --- a/Event/AuthorizationRequestResolveEvent.php +++ b/Event/AuthorizationRequestResolveEvent.php @@ -2,63 +2,36 @@ namespace Trikoder\Bundle\OAuth2Bundle\Event; +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Entities\ScopeEntityInterface; +use League\OAuth2\Server\Entities\UserEntityInterface; use League\OAuth2\Server\RequestTypes\AuthorizationRequest; use Symfony\Component\EventDispatcher\Event; -use Symfony\Component\Security\Core\Exception\LogicException; +use Trikoder\Bundle\OAuth2Bundle\League\Entity\User; +use Zend\Diactoros\Response; +/** + * Class AuthorizationRequestResolveEvent + + * @package Trikoder\Bundle\OAuth2Bundle\Event + */ final class AuthorizationRequestResolveEvent extends Event { - public const AUTHORIZATION_APPROVED = true; - public const AUTHORIZATION_DENIED = false; - public const AUTHORIZATION_PENDING = null; - /** * @var AuthorizationRequest */ private $authorizationRequest; /** - * @var ?string - */ - private $resolutionUri; - - /** - * @var ?bool + * @var Response */ - private $authorizationResolution; + private $response; public function __construct(AuthorizationRequest $authorizationRequest) { $this->authorizationRequest = $authorizationRequest; } - /** - * @return ?bool - */ - public function getAuhorizationResolution(): ?bool - { - return $this->authorizationResolution; - } - - public function resolveAuthorization(bool $authorizationResolution) - { - $this->authorizationResolution = $authorizationResolution; - } - - public function getResolutionUri(): string - { - if (null === $this->resolutionUri) { - throw new LogicException('There is no resolution URI. If the authorization request is not approved nor denied, a resolution URI should be provided'); - } - - return $this->resolutionUri; - } - - public function setResolutionUri(string $resolutionUri) - { - $this->resolutionUri = $resolutionUri; - } - /** * @return string */ @@ -83,6 +56,11 @@ public function getUser() return $this->authorizationRequest->getUser(); } + public function setUser(User $user): void + { + $this->authorizationRequest->setUser($user); + } + /** * @return ScopeEntityInterface[] */ @@ -99,6 +77,14 @@ public function isAuthorizationApproved() return $this->authorizationRequest->isAuthorizationApproved(); } + /** + * @return void + */ + public function approveAuthorization() + { + $this->authorizationRequest->setAuthorizationApproved(true); + } + /** * @return string|null */ @@ -130,4 +116,28 @@ public function getCodeChallengeMethod() { return $this->authorizationRequest->getCodeChallengeMethod(); } + + /** + * @return Response + */ + public function getResponse(): ?Response + { + return $this->response; + } + + /** + * @param Response $response + */ + public function setResponse(Response $response): void + { + $this->response = $response; + } + + /** + * @return bool + */ + public function hasResponse(): bool + { + return $this->response !== null; + } } diff --git a/Event/Listener/AuthorizationEventListener.php b/Event/Listener/AuthorizationEventListener.php new file mode 100644 index 00000000..5f21eb99 --- /dev/null +++ b/Event/Listener/AuthorizationEventListener.php @@ -0,0 +1,21 @@ +authorizationChecker = $authorizationChecker; + $this->session = $session; + $this->requestStack = $requestStack; + $this->urlGenerator = $urlGenerator; + $this->loginRoute = $loginRoute; + } + + /** + * @param AuthorizationRequestResolveEvent $event + */ + public function onAuthorizationRequest(AuthorizationRequestResolveEvent $event): void + { + if (null === $request = $this->requestStack->getMasterRequest()) { + throw new \RuntimeException('Authentication listener depends on the request context'); + } + + if (!$this->authorizationChecker->isGranted('IS_AUTHENTICATED_REMEMBERED')) { + $this->saveTargetPath($this->session, 'main', $request->getUri()); + + $loginUrl = $this->urlGenerator->generate($this->loginRoute); + $event->setResponse(new Response(null, 302, ['Location' => $loginUrl])); + } + } +} diff --git a/Event/Listener/AuthorizationRequestDecisionListener.php b/Event/Listener/AuthorizationRequestDecisionListener.php new file mode 100644 index 00000000..7431d796 --- /dev/null +++ b/Event/Listener/AuthorizationRequestDecisionListener.php @@ -0,0 +1,41 @@ +authorizationDecisionStrategy = $authorizationDecisionStrategy; + } + + public function onAuthorizationRequest(AuthorizationRequestResolveEvent $event): void + { + // if request is already approved by other listener, there is nothing left to do + if ($event->isAuthorizationApproved()) { + return; + } + + // delegate to configured authorization strategy + $this->authorizationDecisionStrategy->decide($event); + } +} diff --git a/Event/Listener/AuthorizationRequestUserResolvingListener.php b/Event/Listener/AuthorizationRequestUserResolvingListener.php new file mode 100644 index 00000000..57dc8f8c --- /dev/null +++ b/Event/Listener/AuthorizationRequestUserResolvingListener.php @@ -0,0 +1,41 @@ +setUser($this->getUserEntity()); + } + + private function getUserEntity(): User + { + $userEntity = new User(); + + $user = $this->security->getUser(); + if ($user) { + $username = $user instanceof UserInterface ? $user->getUsername() : (string) $user; + $userEntity->setIdentifier($username); + } + + return $userEntity; + } +} diff --git a/Model/AuthorizationDecision/AlwaysAllowDecisionStrategy.php b/Model/AuthorizationDecision/AlwaysAllowDecisionStrategy.php new file mode 100644 index 00000000..3bca50da --- /dev/null +++ b/Model/AuthorizationDecision/AlwaysAllowDecisionStrategy.php @@ -0,0 +1,13 @@ +approveAuthorization(); + } +} diff --git a/Model/AuthorizationDecision/AuthorizationDecisionStrategy.php b/Model/AuthorizationDecision/AuthorizationDecisionStrategy.php new file mode 100644 index 00000000..7bd987c3 --- /dev/null +++ b/Model/AuthorizationDecision/AuthorizationDecisionStrategy.php @@ -0,0 +1,9 @@ +consentApprovalRoute = $consentApprovalRoute; + $this->uriSigner = $uriSigner; + $this->requestStack = $requestStack; + $this->urlGenerator = $urlGenerator; + } + + /** + * @param AuthorizationRequestResolveEvent $event + */ + public function decide(AuthorizationRequestResolveEvent $event): void + { + if (null === $request = $this->requestStack->getMasterRequest()) { + throw new \RuntimeException('Consent decision strategy depends on the request context'); + } + + // if the request carries approval result + if ($this->canResolveAuthorizationRequest($event, $request)) { + if ($this->isAuthorizationAllowed($request)) { + $event->approveAuthorization(); + } + + // disapproved consent is handled by League component + return; + } + + $event->setResponse($this->createRedirectToConsentResponse($event)); + } + + private function canResolveAuthorizationRequest(AuthorizationRequestResolveEvent $event, Request $request) + { + if (!$request->query->has(self::ATTRIBUTE_DECISION)) { + return false; + } + + $currentUri = $request->getRequestUri(); + if (!$this->uriSigner->check($currentUri)) { + return false; + } + + if ($request->query->get('client_id') !== $event->getClient()->getIdentifier()) { + return false; + } + if ($request->query->get('response_type') !== $this->getResponseType($event)) { + return false; + } + if ($request->query->get('redirect_uri') !== $event->getRedirectUri()) { + return false; + } + if ($request->query->get('scope') !== $this->getScope($event)) { + return false; + } + + return true; + } + + private function createRedirectToConsentResponse(AuthorizationRequestResolveEvent $event): Response + { + $params = [ + 'client_id' => $event->getClient()->getIdentifier(), + 'response_type' => $this->getResponseType($event), + ]; + if (null !== $redirectUri = $event->getRedirectUri()) { + $params['redirect_uri'] = $redirectUri; + } + if (null !== $state = $event->getState()) { + $params['state'] = $state; + } + $scope = $this->getScope($event); + if (null !== $scope) { + $params['scope'] = $scope; + } + + $redirectUri = $this->urlGenerator->generate($this->consentApprovalRoute, $params); + return new Response(null, 302, ['Location' => $redirectUri]); + } + + private function getResponseType(AuthorizationRequestResolveEvent $event): string + { + switch ($event->getGrantTypeId()) { + case OAuth2Grants::AUTHORIZATION_CODE: + return 'code'; + case OAuth2Grants::IMPLICIT: + return 'token'; + default: + return $event->getGrantTypeId(); + } + } + + private function getScope(AuthorizationRequestResolveEvent $event): ?string + { + $scopes = array_map(function (Scope $scope) { + return $scope->getIdentifier(); + }, $event->getScopes()); + + if (empty($scopes)) { + return null; + } + + return implode(' ', $scopes); + } + + private function isAuthorizationAllowed(Request $request): bool + { + return $request->get(self::ATTRIBUTE_DECISION) === self::ATTRIBUTE_DECISION_ALLOW; + } +} diff --git a/OAuth2Events.php b/OAuth2Events.php index 8ef1378c..6dbda40d 100644 --- a/OAuth2Events.php +++ b/OAuth2Events.php @@ -5,7 +5,7 @@ final class OAuth2Events { /** - * The USER_RESOLVE event occurrs when the client requests a "password" + * The USER_RESOLVE event occurs when the client requests a "password" * grant type from the authorization server. * * You should set a valid user here if applicable. @@ -13,7 +13,7 @@ final class OAuth2Events public const USER_RESOLVE = 'trikoder.oauth2.user_resolve'; /** - * The SCOPE_RESOLVE event occurrs right before the user obtains their + * The SCOPE_RESOLVE event occurs right before the user obtains their * valid access token. * * You could alter the access token's scope here. @@ -21,7 +21,7 @@ final class OAuth2Events public const SCOPE_RESOLVE = 'trikoder.oauth2.scope_resolve'; /** - * The AUTHORIZATION_REQUEST_RESOLVE event occurrs right before the system + * The AUTHORIZATION_REQUEST_RESOLVE event occurs right before the system * complete the authorization request. * * You could approve or deny the authorization request, or set the uri where diff --git a/Resources/config/services.xml b/Resources/config/services.xml index b32d818e..6116d40e 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -80,11 +80,37 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + From 2754317d677e7511781680c3adfcce907a4b7481 Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Wed, 13 Feb 2019 08:17:04 +0100 Subject: [PATCH 17/44] adds authorization strategy configuration --- DependencyInjection/Configuration.php | 5 ++++ .../TrikoderOAuth2Extension.php | 11 ++++++++ ...horizationRequestUserResolvingListener.php | 10 ++++++++ Resources/config/services.xml | 25 +++++++++---------- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index db931f29..8834ed51 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -58,6 +58,11 @@ private function createAuthorizationServerNode(): NodeDefinition ->cannotBeEmpty() ->defaultValue('PT10M') ->end() + ->enumNode('authorization_strategy') + ->info("What strategy should be used to authorize user.\nAvailable options are: user_consent - let user authorize the client request, always_allow - silently authorizes all client requests") + ->values(['user_consent', 'always_allow']) + ->defaultValue('user_consent') + ->end() ->end() ; diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 28ec8502..bf491120 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -16,6 +16,7 @@ use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\Grant as GrantType; use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\RedirectUri as RedirectUriType; use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\Scope as ScopeType; +use Trikoder\Bundle\OAuth2Bundle\Event\Listener\AuthorizationRequestAuthenticationListener; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\Scope as ScopeModel; @@ -95,6 +96,7 @@ private function configureAuthorizationServer(ContainerBuilder $container, array ]); $this->configureGrants($container, $config); + $this->configureAuthorizationStrategy($container, $config['authorization_strategy']); } private function configureGrants(ContainerBuilder $container, array $config): void @@ -206,4 +208,13 @@ private function configureScopes(ContainerBuilder $container, array $scopes): vo ]); } } + + private function configureAuthorizationStrategy(ContainerBuilder $container, string $authorizationStrategy) + { + if ($authorizationStrategy == 'always_allow') { + $container + ->getDefinition('trikoder.oauth2.event_listener.authorization.decision') + ->replaceArgument(0, new Reference('trikoder.oauth2.authorization_decision_strategy.always_allow')); + } + } } diff --git a/Event/Listener/AuthorizationRequestUserResolvingListener.php b/Event/Listener/AuthorizationRequestUserResolvingListener.php index 57dc8f8c..ef2c469e 100644 --- a/Event/Listener/AuthorizationRequestUserResolvingListener.php +++ b/Event/Listener/AuthorizationRequestUserResolvingListener.php @@ -21,6 +21,16 @@ class AuthorizationRequestUserResolvingListener */ private $security; + /** + * AuthorizationRequestUserResolvingListener constructor. + * + * @param Security $security + */ + public function __construct(Security $security) + { + $this->security = $security; + } + public function onAuthorizationRequest(AuthorizationRequestResolveEvent $authRequest) { $authRequest->setUser($this->getUserEntity()); diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 6116d40e..58ccea3a 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -86,25 +86,24 @@ - - + + - - - - - - - - + + + + + + + + + - - - + From fd2f6f80f6bc52f1ab12b42be63c0e4d7c0120ad Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Wed, 13 Feb 2019 08:20:37 +0100 Subject: [PATCH 18/44] configure openid connect authentiction listener --- .../TrikoderOAuth2Extension.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index c78c9240..3e17d80e 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -218,4 +218,38 @@ private function configureAuthorizationStrategy(ContainerBuilder $container, str ->replaceArgument(0, new Reference('trikoder.oauth2.authorization_decision_strategy.always_allow')); } } + + private function configureOpenIDConnect(ContainerBuilder $container, array $openid_connect): void + { + if (isset($openid_connect['enabled']) && $openid_connect['enabled']) { + $container + ->getDefinition('league.oauth2.server.authorization_server') + ->setArgument(5, new Reference('openid_connect_server.id_token_response')) + ; + $container + ->setDefinition( + 'trikoder.oauth2.event_listener.require_authentication.', + $this->cerateAuthorizationRequestAuthenticationListenerDefinition() + ) + ; + } + } + + private function cerateAuthorizationRequestAuthenticationListenerDefinition(): Definition + { + return (new Definition(AuthorizationRequestAuthenticationListener::class)) + ->setArguments([ + new Reference('security.authorization_checker'), + new Reference('session'), + new Reference('request_stack'), + new Reference('router'), + ]) + ->addTag('kernel.event_listener', [ + 'event' => 'trikoder.oauth2.authorization_request_resolve', + 'method' => 'onAuthorizationRequest', + 'priority' => 300 + ]) + ; + + } } From 39e479f805714452f03f43cd1cba8d6af0f95fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Tue, 19 Feb 2019 08:13:32 +0100 Subject: [PATCH 19/44] Use Security class instead of TokenStorageInterface --- Controller/AuthorizationController.php | 19 ++++++++----------- Resources/config/services.xml | 2 +- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/Controller/AuthorizationController.php b/Controller/AuthorizationController.php index 0b0678b3..2177a45b 100644 --- a/Controller/AuthorizationController.php +++ b/Controller/AuthorizationController.php @@ -7,8 +7,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\User\UserInterface; use Trikoder\Bundle\OAuth2Bundle\Event\AuthorizationRequestResolveEvent; use Trikoder\Bundle\OAuth2Bundle\League\Entity\User; @@ -23,19 +22,19 @@ final class AuthorizationController private $server; /** - * @var TokenStorageInterface + * @var Security */ - private $tokenStorage; + private $security; /** * @var EventDispatcherInterface */ private $eventDispatcher; - public function __construct(AuthorizationServer $server, TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher) + public function __construct(AuthorizationServer $server, Security $security, EventDispatcherInterface $eventDispatcher) { $this->server = $server; - $this->tokenStorage = $tokenStorage; + $this->security = $security; $this->eventDispatcher = $eventDispatcher; } @@ -70,11 +69,9 @@ private function getUserEntity(): User { $userEntity = new User(); - $token = $this->tokenStorage->getToken(); - if ($token instanceof TokenInterface) { - $user = $token->getUser(); - $username = $user instanceof UserInterface ? $user->getUsername() : (string) $user; - $userEntity->setIdentifier($username); + $user = $this->security->getUser(); + if ($user instanceof UserInterface) { + $userEntity->setIdentifier($user->getUsername()); } return $userEntity; diff --git a/Resources/config/services.xml b/Resources/config/services.xml index b32d818e..65c63628 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -80,7 +80,7 @@ - + From c0602262a51fe4a3640efae3220f1867fac7ac39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Tue, 19 Feb 2019 08:32:04 +0100 Subject: [PATCH 20/44] Use integers instead of booleans values for authorization resolution. --- Event/AuthorizationRequestResolveEvent.php | 26 +++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/Event/AuthorizationRequestResolveEvent.php b/Event/AuthorizationRequestResolveEvent.php index b047c478..be8ed29b 100644 --- a/Event/AuthorizationRequestResolveEvent.php +++ b/Event/AuthorizationRequestResolveEvent.php @@ -6,14 +6,19 @@ use League\OAuth2\Server\Entities\ScopeEntityInterface; use League\OAuth2\Server\Entities\UserEntityInterface; use League\OAuth2\Server\RequestTypes\AuthorizationRequest; +use LogicException; use Symfony\Component\EventDispatcher\Event; -use Symfony\Component\Security\Core\Exception\LogicException; final class AuthorizationRequestResolveEvent extends Event { - public const AUTHORIZATION_APPROVED = true; - public const AUTHORIZATION_DENIED = false; - public const AUTHORIZATION_PENDING = null; + public const AUTHORIZATION_PENDING = 0; + public const AUTHORIZATION_APPROVED = 1; + public const AUTHORIZATION_DENIED = 2; + + public const ALLOWED_RESOLUTIONS = [ + self::AUTHORIZATION_APPROVED, + self::AUTHORIZATION_DENIED, + ]; /** * @var AuthorizationRequest @@ -26,25 +31,26 @@ final class AuthorizationRequestResolveEvent extends Event private $resolutionUri; /** - * @var ?bool + * @var int */ private $authorizationResolution; public function __construct(AuthorizationRequest $authorizationRequest) { $this->authorizationRequest = $authorizationRequest; + $this->authorizationResolution = self::AUTHORIZATION_PENDING; } - /** - * @return ?bool - */ - public function getAuhorizationResolution(): ?bool + public function getAuhorizationResolution(): int { return $this->authorizationResolution; } - public function resolveAuthorization(bool $authorizationResolution) + public function resolveAuthorization(int $authorizationResolution): void { + if (!\in_array($authorizationResolution, self::ALLOWED_RESOLUTIONS, true)) { + throw new LogicException('The given resolution code is not allowed.'); + } $this->authorizationResolution = $authorizationResolution; } From b6f385aa6f15a7f60a980aa376fc5d4e2ab27bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Tue, 19 Feb 2019 08:33:44 +0100 Subject: [PATCH 21/44] Test authorization code request with faked redirect uri --- .../Acceptance/AuthorizationEndpointTest.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Tests/Acceptance/AuthorizationEndpointTest.php b/Tests/Acceptance/AuthorizationEndpointTest.php index a8ef0b9e..46e0653c 100644 --- a/Tests/Acceptance/AuthorizationEndpointTest.php +++ b/Tests/Acceptance/AuthorizationEndpointTest.php @@ -77,6 +77,41 @@ public function testCodeRequestRedirectToResolutionUri() $this->assertEquals('/authorize/consent', $redirectUri); } + public function testFailedCodeRequestRedirectWithFakedRedirectUri() + { + $this->client + ->getContainer() + ->get('event_dispatcher') + ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event) { + $event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED); + }); + + timecop_freeze(new DateTime()); + + $this->client->request( + 'GET', + '/authorize', + [ + 'client_id' => FixtureFactory::FIXTURE_CLIENT_FIRST, + 'response_type' => 'code', + 'state' => 'foobar', + 'redirect_uri' => 'https://example.org/oauth2/malicious-uri', + ] + ); + + timecop_return(); + + $response = $this->client->getResponse(); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame('application/json', $response->headers->get('Content-Type')); + + $jsonResponse = json_decode($response->getContent(), true); + + $this->assertSame('invalid_client', $jsonResponse['error']); + $this->assertSame('Client authentication failed', $jsonResponse['message']); + } + public function testFailedAuthorizeRequest() { $this->client->request( From d45129d2b471db6b73837dce50a3ab90aeb61ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Wed, 20 Feb 2019 09:10:21 +0100 Subject: [PATCH 22/44] Rename AuthCode to AuthorizationCode Classes under the `Trikoder\Bundle\OAuth2Bundle\League` remain named as AuthCode to reflect the naming choosen by the league/oauth2-server project. --- .../TrikoderOAuth2Extension.php | 2 +- League/Repository/AuthCodeRepository.php | 54 +++++++++---------- Manager/AuthCodeManagerInterface.php | 12 ----- Manager/AuthorizationCodeManagerInterface.php | 12 +++++ Manager/Doctrine/AuthCodeManager.php | 37 ------------- Manager/Doctrine/AuthorizationCodeManager.php | 37 +++++++++++++ Manager/InMemory/AuthCodeManager.php | 24 --------- Manager/InMemory/AuthorizationCodeManager.php | 24 +++++++++ Model/{AuthCode.php => AuthorizationCode.php} | 2 +- ...Code.orm.xml => AuthorizationCode.orm.xml} | 2 +- Resources/config/services.xml | 2 +- Resources/config/storage/doctrine.xml | 4 +- Resources/config/storage/in_memory.xml | 4 +- Tests/Acceptance/AbstractAcceptanceTest.php | 4 +- Tests/Acceptance/TokenEndpointTest.php | 4 +- Tests/Fixtures/FixtureFactory.php | 24 ++++----- Tests/Integration/AbstractIntegrationTest.php | 8 +-- Tests/TestHelper.php | 4 +- Tests/TestKernel.php | 4 +- 19 files changed, 132 insertions(+), 132 deletions(-) delete mode 100644 Manager/AuthCodeManagerInterface.php create mode 100644 Manager/AuthorizationCodeManagerInterface.php delete mode 100644 Manager/Doctrine/AuthCodeManager.php create mode 100644 Manager/Doctrine/AuthorizationCodeManager.php delete mode 100644 Manager/InMemory/AuthCodeManager.php create mode 100644 Manager/InMemory/AuthorizationCodeManager.php rename Model/{AuthCode.php => AuthorizationCode.php} (98%) rename Resources/config/doctrine/model/{AuthCode.orm.xml => AuthorizationCode.orm.xml} (90%) diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 28ec8502..522e75b4 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -167,7 +167,7 @@ private function configureDoctrinePersistence(ContainerBuilder $container, array ; $container - ->getDefinition('trikoder.oauth2.manager.doctrine.auth_code_manager') + ->getDefinition('trikoder.oauth2.manager.doctrine.authorization_code_manager') ->replaceArgument('$entityManager', $entityManager) ; diff --git a/League/Repository/AuthCodeRepository.php b/League/Repository/AuthCodeRepository.php index 97948cf7..e8cbc51f 100644 --- a/League/Repository/AuthCodeRepository.php +++ b/League/Repository/AuthCodeRepository.php @@ -6,17 +6,17 @@ use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface; use Trikoder\Bundle\OAuth2Bundle\Converter\ScopeConverter; -use Trikoder\Bundle\OAuth2Bundle\League\Entity\AuthCode as AuthCodeEntity; -use Trikoder\Bundle\OAuth2Bundle\Manager\AuthCodeManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\League\Entity\AuthCode; +use Trikoder\Bundle\OAuth2Bundle\Manager\AuthorizationCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; -use Trikoder\Bundle\OAuth2Bundle\Model\AuthCode as AuthCodeModel; +use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationCode; final class AuthCodeRepository implements AuthCodeRepositoryInterface { /** - * @var AuthCodeManagerInterface + * @var AuthorizationCodeManagerInterface */ - private $authCodeManager; + private $authorizationCodeManager; /** * @var ClientManagerInterface @@ -29,11 +29,11 @@ final class AuthCodeRepository implements AuthCodeRepositoryInterface private $scopeConverter; public function __construct( - AuthCodeManagerInterface $authCodeManager, + AuthorizationCodeManagerInterface $authorizationCodeManager, ClientManagerInterface $clientManager, ScopeConverter $scopeConverter ) { - $this->authCodeManager = $authCodeManager; + $this->authorizationCodeManager = $authorizationCodeManager; $this->clientManager = $clientManager; $this->scopeConverter = $scopeConverter; } @@ -43,23 +43,23 @@ public function __construct( */ public function getNewAuthCode() { - return new AuthCodeEntity(); + return new AuthCode(); } /** * {@inheritdoc} */ - public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity) + public function persistNewAuthCode(AuthCodeEntityInterface $authCode) { - $authCode = $this->authCodeManager->find($authCodeEntity->getIdentifier()); + $authorizationCode = $this->authorizationCodeManager->find($authCode->getIdentifier()); - if (null !== $authCode) { + if (null !== $authorizationCode) { throw UniqueTokenIdentifierConstraintViolationException::create(); } - $authCode = $this->buildAuthCodeModel($authCodeEntity); + $authorizationCode = $this->buildAuthorizationCode($authCode); - $this->authCodeManager->save($authCode); + $this->authorizationCodeManager->save($authorizationCode); } /** @@ -67,15 +67,15 @@ public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity) */ public function revokeAuthCode($codeId) { - $authCode = $this->authCodeManager->find($codeId); + $authorizationCode = $this->authorizationCodeManager->find($codeId); if (null === $codeId) { return; } - $authCode->revoke(); + $authorizationCode->revoke(); - $this->authCodeManager->save($authCode); + $this->authorizationCodeManager->save($authorizationCode); } /** @@ -83,27 +83,27 @@ public function revokeAuthCode($codeId) */ public function isAuthCodeRevoked($codeId) { - $authCode = $this->authCodeManager->find($codeId); + $authorizationCode = $this->authorizationCodeManager->find($codeId); - if (null === $authCode) { + if (null === $authorizationCode) { return true; } - return $authCode->isRevoked(); + return $authorizationCode->isRevoked(); } - private function buildAuthCodeModel(AuthCodeEntity $authCodeEntity): AuthCodeModel + private function buildAuthorizationCode(AuthCode $authCode): AuthorizationCode { - $client = $this->clientManager->find($authCodeEntity->getClient()->getIdentifier()); + $client = $this->clientManager->find($authCode->getClient()->getIdentifier()); - $authCode = new AuthCodeModel( - $authCodeEntity->getIdentifier(), - $authCodeEntity->getExpiryDateTime(), + $authorizationCode = new AuthorizationCode( + $authCode->getIdentifier(), + $authCode->getExpiryDateTime(), $client, - $authCodeEntity->getUserIdentifier(), - $this->scopeConverter->toDomainArray($authCodeEntity->getScopes()) + $authCode->getUserIdentifier(), + $this->scopeConverter->toDomainArray($authCode->getScopes()) ); - return $authCode; + return $authorizationCode; } } diff --git a/Manager/AuthCodeManagerInterface.php b/Manager/AuthCodeManagerInterface.php deleted file mode 100644 index 810c63d2..00000000 --- a/Manager/AuthCodeManagerInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -entityManager = $entityManager; - } - - /** - * {@inheritdoc} - */ - public function find(string $identifier): ?AuthCode - { - return $this->entityManager->find(AuthCode::class, $identifier); - } - - /** - * {@inheritdoc} - */ - public function save(AuthCode $authCode): void - { - $this->entityManager->persist($authCode); - $this->entityManager->flush(); - } -} diff --git a/Manager/Doctrine/AuthorizationCodeManager.php b/Manager/Doctrine/AuthorizationCodeManager.php new file mode 100644 index 00000000..08fd5f5f --- /dev/null +++ b/Manager/Doctrine/AuthorizationCodeManager.php @@ -0,0 +1,37 @@ +entityManager = $entityManager; + } + + /** + * {@inheritdoc} + */ + public function find(string $identifier): ?AuthorizationCode + { + return $this->entityManager->find(AuthorizationCode::class, $identifier); + } + + /** + * {@inheritdoc} + */ + public function save(AuthorizationCode $authorizationCode): void + { + $this->entityManager->persist($authorizationCode); + $this->entityManager->flush(); + } +} diff --git a/Manager/InMemory/AuthCodeManager.php b/Manager/InMemory/AuthCodeManager.php deleted file mode 100644 index 1b3e412b..00000000 --- a/Manager/InMemory/AuthCodeManager.php +++ /dev/null @@ -1,24 +0,0 @@ -authCodes[$identifier] ?? null; - } - - public function save(AuthCode $authCode): void - { - $this->authCodes[$authCode->getIdentifier()] = $authCode; - } -} diff --git a/Manager/InMemory/AuthorizationCodeManager.php b/Manager/InMemory/AuthorizationCodeManager.php new file mode 100644 index 00000000..d2c2de46 --- /dev/null +++ b/Manager/InMemory/AuthorizationCodeManager.php @@ -0,0 +1,24 @@ +authorizationCodes[$identifier] ?? null; + } + + public function save(AuthorizationCode $authorizationCode): void + { + $this->authorizationCodes[$authorizationCode->getIdentifier()] = $authorizationCode; + } +} diff --git a/Model/AuthCode.php b/Model/AuthorizationCode.php similarity index 98% rename from Model/AuthCode.php rename to Model/AuthorizationCode.php index 3d3ac7b8..82eb6236 100644 --- a/Model/AuthCode.php +++ b/Model/AuthorizationCode.php @@ -4,7 +4,7 @@ use DateTime; -class AuthCode +class AuthorizationCode { /** * @var string diff --git a/Resources/config/doctrine/model/AuthCode.orm.xml b/Resources/config/doctrine/model/AuthorizationCode.orm.xml similarity index 90% rename from Resources/config/doctrine/model/AuthCode.orm.xml rename to Resources/config/doctrine/model/AuthorizationCode.orm.xml index c2d19457..1beaa55b 100644 --- a/Resources/config/doctrine/model/AuthCode.orm.xml +++ b/Resources/config/doctrine/model/AuthorizationCode.orm.xml @@ -2,7 +2,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd"> - + diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 65c63628..15899220 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -30,7 +30,7 @@ - + diff --git a/Resources/config/storage/doctrine.xml b/Resources/config/storage/doctrine.xml index f9e39b7a..a411da0d 100644 --- a/Resources/config/storage/doctrine.xml +++ b/Resources/config/storage/doctrine.xml @@ -7,7 +7,7 @@ - + @@ -20,7 +20,7 @@ - + diff --git a/Resources/config/storage/in_memory.xml b/Resources/config/storage/in_memory.xml index 2e7564a6..1e3ceb29 100644 --- a/Resources/config/storage/in_memory.xml +++ b/Resources/config/storage/in_memory.xml @@ -7,13 +7,13 @@ - + - + diff --git a/Tests/Acceptance/AbstractAcceptanceTest.php b/Tests/Acceptance/AbstractAcceptanceTest.php index b779be4b..1dcd52cb 100644 --- a/Tests/Acceptance/AbstractAcceptanceTest.php +++ b/Tests/Acceptance/AbstractAcceptanceTest.php @@ -6,7 +6,7 @@ use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; -use Trikoder\Bundle\OAuth2Bundle\Manager\AuthCodeManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Manager\AuthorizationCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; @@ -38,7 +38,7 @@ protected function setUp() $this->client->getContainer()->get(ClientManagerInterface::class), $this->client->getContainer()->get(AccessTokenManagerInterface::class), $this->client->getContainer()->get(RefreshTokenManagerInterface::class), - $this->client->getContainer()->get(AuthCodeManagerInterface::class) + $this->client->getContainer()->get(AuthorizationCodeManagerInterface::class) ); } } diff --git a/Tests/Acceptance/TokenEndpointTest.php b/Tests/Acceptance/TokenEndpointTest.php index 48aa86d5..a1b97e3a 100644 --- a/Tests/Acceptance/TokenEndpointTest.php +++ b/Tests/Acceptance/TokenEndpointTest.php @@ -4,7 +4,7 @@ use DateTime; use Trikoder\Bundle\OAuth2Bundle\Event\UserResolveEvent; -use Trikoder\Bundle\OAuth2Bundle\Manager\AuthCodeManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Manager\AuthorizationCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Tests\Fixtures\FixtureFactory; use Trikoder\Bundle\OAuth2Bundle\Tests\TestHelper; @@ -104,7 +104,7 @@ public function testSuccessfulAuthorizationCodeRequest() { $authCode = $this->client ->getContainer() - ->get(AuthCodeManagerInterface::class) + ->get(AuthorizationCodeManagerInterface::class) ->find(FixtureFactory::FIXTURE_AUTH_CODE); timecop_freeze(new DateTime()); diff --git a/Tests/Fixtures/FixtureFactory.php b/Tests/Fixtures/FixtureFactory.php index 59c67aa5..2c2dbc31 100644 --- a/Tests/Fixtures/FixtureFactory.php +++ b/Tests/Fixtures/FixtureFactory.php @@ -4,12 +4,12 @@ use DateTime; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; -use Trikoder\Bundle\OAuth2Bundle\Manager\AuthCodeManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Manager\AuthorizationCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken; -use Trikoder\Bundle\OAuth2Bundle\Model\AuthCode; +use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationCode; use Trikoder\Bundle\OAuth2Bundle\Model\Client; use Trikoder\Bundle\OAuth2Bundle\Model\Grant; use Trikoder\Bundle\OAuth2Bundle\Model\RedirectUri; @@ -52,7 +52,7 @@ public static function initializeFixtures( ClientManagerInterface $clientManager, AccessTokenManagerInterface $accessTokenManager, RefreshTokenManagerInterface $refreshTokenManager, - AuthCodeManagerInterface $authCodeManager + AuthorizationCodeManagerInterface $authCodeManager ): void { foreach (self::createScopes() as $scope) { $scopeManager->save($scope); @@ -70,8 +70,8 @@ public static function initializeFixtures( $refreshTokenManager->save($refreshToken); } - foreach (self::createAuthCodes($clientManager) as $authCode) { - $authCodeManager->save($authCode); + foreach (self::createAuthorizationCodes($clientManager) as $authorizationCode) { + $authCodeManager->save($authorizationCode); } } @@ -178,13 +178,13 @@ public static function createRefreshTokens(AccessTokenManagerInterface $accessTo } /** - * @return AuthCode[] + * @return AuthorizationCode[] */ - public static function createAuthCodes(ClientManagerInterface $clientManager): array + public static function createAuthorizationCodes(ClientManagerInterface $clientManager): array { - $authCodes = []; + $authorizationCodes = []; - $authCodes[] = new AuthCode( + $authorizationCodes[] = new AuthorizationCode( self::FIXTURE_AUTH_CODE, new DateTime('+2 minute'), $clientManager->find(self::FIXTURE_CLIENT_FIRST), @@ -192,7 +192,7 @@ public static function createAuthCodes(ClientManagerInterface $clientManager): a [] ); - $authCodes[] = new AuthCode( + $authorizationCodes[] = new AuthorizationCode( self::FIXTURE_AUTH_CODE_DIFFERENT_CLIENT, new DateTime('+2 minute'), $clientManager->find(self::FIXTURE_CLIENT_SECOND), @@ -200,7 +200,7 @@ public static function createAuthCodes(ClientManagerInterface $clientManager): a [] ); - $authCodes[] = new AuthCode( + $authorizationCodes[] = new AuthorizationCode( self::FIXTURE_AUTH_CODE_EXPIRED, new DateTime('-30 minute'), $clientManager->find(self::FIXTURE_CLIENT_FIRST), @@ -208,7 +208,7 @@ public static function createAuthCodes(ClientManagerInterface $clientManager): a [] ); - return $authCodes; + return $authorizationCodes; } /** diff --git a/Tests/Integration/AbstractIntegrationTest.php b/Tests/Integration/AbstractIntegrationTest.php index 1d85a6e6..0023863b 100644 --- a/Tests/Integration/AbstractIntegrationTest.php +++ b/Tests/Integration/AbstractIntegrationTest.php @@ -32,10 +32,10 @@ use Trikoder\Bundle\OAuth2Bundle\League\Repository\ScopeRepository; use Trikoder\Bundle\OAuth2Bundle\League\Repository\UserRepository; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; -use Trikoder\Bundle\OAuth2Bundle\Manager\AuthCodeManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Manager\AuthorizationCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\AccessTokenManager; -use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\AuthCodeManager; +use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\AuthorizationCodeManager; use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\ClientManager; use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\RefreshTokenManager; use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\ScopeManager; @@ -66,7 +66,7 @@ abstract class AbstractIntegrationTest extends TestCase protected $accessTokenManager; /** - * @var AuthCodeManagerInterface + * @var AuthorizationCodeManagerInterface */ protected $authCodeManager; @@ -99,7 +99,7 @@ protected function setUp() $this->clientManager = new ClientManager(); $this->accessTokenManager = new AccessTokenManager(); $this->refreshTokenManager = new RefreshTokenManager(); - $this->authCodeManager = new AuthCodeManager(); + $this->authCodeManager = new AuthorizationCodeManager(); $this->eventDispatcher = new EventDispatcher(); FixtureFactory::initializeFixtures( diff --git a/Tests/TestHelper.php b/Tests/TestHelper.php index 5724c11a..e249eca3 100644 --- a/Tests/TestHelper.php +++ b/Tests/TestHelper.php @@ -13,7 +13,7 @@ use Trikoder\Bundle\OAuth2Bundle\League\Entity\Client as ClientEntity; use Trikoder\Bundle\OAuth2Bundle\League\Entity\Scope as ScopeEntity; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken as AccessTokenModel; -use Trikoder\Bundle\OAuth2Bundle\Model\AuthCode as AuthCodeModel; +use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationCode as AuthorizationCodeModel; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken as RefreshTokenModel; final class TestHelper @@ -40,7 +40,7 @@ public static function generateEncryptedPayload(RefreshTokenModel $refreshToken) } } - public static function generateEncryptedAuthCodePayload(AuthCodeModel $authCode): ?string + public static function generateEncryptedAuthCodePayload(AuthorizationCodeModel $authCode): ?string { $payload = json_encode([ 'client_id' => $authCode->getClient()->getIdentifier(), diff --git a/Tests/TestKernel.php b/Tests/TestKernel.php index b3dbb4b4..661181d7 100644 --- a/Tests/TestKernel.php +++ b/Tests/TestKernel.php @@ -9,7 +9,7 @@ use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Routing\RouteCollectionBuilder; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; -use Trikoder\Bundle\OAuth2Bundle\Manager\AuthCodeManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Manager\AuthorizationCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; @@ -199,7 +199,7 @@ public function process(ContainerBuilder $container) $container ->getDefinition( $container - ->getAlias(AuthCodeManagerInterface::class) + ->getAlias(AuthorizationCodeManagerInterface::class) ->setPublic(true) ) ->setPublic(true) From f3ada2fcdce4bb8e645155ac5b413feacf11dad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Wed, 20 Feb 2019 09:31:31 +0100 Subject: [PATCH 23/44] Method AbstractIntegrationTest::handleAuthorizeRequest returns Response instead of array --- Tests/Integration/AbstractIntegrationTest.php | 14 ++-- Tests/Integration/AuthorizationServerTest.php | 64 +++++++++++++------ 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/Tests/Integration/AbstractIntegrationTest.php b/Tests/Integration/AbstractIntegrationTest.php index 0023863b..8485244a 100644 --- a/Tests/Integration/AbstractIntegrationTest.php +++ b/Tests/Integration/AbstractIntegrationTest.php @@ -19,6 +19,7 @@ use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; use League\OAuth2\Server\Repositories\UserRepositoryInterface; use League\OAuth2\Server\ResourceServer; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Symfony\Bundle\FrameworkBundle\Tests\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -211,7 +212,7 @@ protected function handleResourceRequest(ServerRequestInterface $serverRequest): return $serverRequest; } - protected function handleAuthorizeRequest(ServerRequestInterface $serverRequest, $approved = true): array + protected function handleAuthorizeRequest(ServerRequestInterface $serverRequest, $approved = true): ResponseInterface { $response = new Response(); @@ -227,12 +228,15 @@ protected function handleAuthorizeRequest(ServerRequestInterface $serverRequest, $response = $e->generateHttpResponse($response); } - if (!$response->hasHeader('Location')) { - return json_decode($response->getBody(), true); - } + return $response; + } + + protected function extractQueryDataFromUri(string $uri): array + { + $uriObject = new \Zend\Diactoros\Uri($uri); $data = []; - parse_str(parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $data); + parse_str($uriObject->getQuery(), $data); return $data; } diff --git a/Tests/Integration/AuthorizationServerTest.php b/Tests/Integration/AuthorizationServerTest.php index 1c0dc650..1448dfa5 100644 --- a/Tests/Integration/AuthorizationServerTest.php +++ b/Tests/Integration/AuthorizationServerTest.php @@ -394,7 +394,11 @@ public function testSuccessfulCodeRequest(): void $response = $this->handleAuthorizeRequest($request); // Response assertions. - $this->assertArrayHasKey('code', $response); + $this->assertSame(302, $response->getStatusCode()); + $this->assertTrue($response->hasHeader('Location')); + $this->assertStringStartsWith(FixtureFactory::FIXTURE_CLIENT_FIRST_REDIRECT_URI, $response->getHeaderLine('Location')); + $queryData = $this->extractQueryDataFromUri($response->getHeaderLine('Location')); + $this->assertArrayHasKey('code', $queryData); } public function testSuccessfulCodeRequestWithState(): void @@ -408,8 +412,12 @@ public function testSuccessfulCodeRequestWithState(): void $response = $this->handleAuthorizeRequest($request); // Response assertions. - $this->assertArrayHasKey('code', $response); - $this->assertSame('quzbaz', $response['state']); + $this->assertSame(302, $response->getStatusCode()); + $this->assertTrue($response->hasHeader('Location')); + $this->assertStringStartsWith(FixtureFactory::FIXTURE_CLIENT_FIRST_REDIRECT_URI, $response->getHeaderLine('Location')); + $queryData = $this->extractQueryDataFromUri($response->getHeaderLine('Location')); + $this->assertArrayHasKey('code', $queryData); + $this->assertSame('quzbaz', $queryData['state']); } public function testSuccessfulCodeRequestWithRedirectUri(): void @@ -423,7 +431,11 @@ public function testSuccessfulCodeRequestWithRedirectUri(): void $response = $this->handleAuthorizeRequest($request); // Response assertions. - $this->assertArrayHasKey('code', $response); + $this->assertSame(302, $response->getStatusCode()); + $this->assertTrue($response->hasHeader('Location')); + $this->assertStringStartsWith(FixtureFactory::FIXTURE_CLIENT_FIRST_REDIRECT_URI, $response->getHeaderLine('Location')); + $queryData = $this->extractQueryDataFromUri($response->getHeaderLine('Location')); + $this->assertArrayHasKey('code', $queryData); } public function testCodeRequestWithInvalidScope(): void @@ -437,9 +449,13 @@ public function testCodeRequestWithInvalidScope(): void $response = $this->handleAuthorizeRequest($request); // Response assertions. - $this->assertSame('invalid_scope', $response['error']); - $this->assertSame('The requested scope is invalid, unknown, or malformed', $response['message']); - $this->assertSame('Check the `non_existing` scope', $response['hint']); + $this->assertSame(302, $response->getStatusCode()); + $this->assertTrue($response->hasHeader('Location')); + $this->assertStringStartsWith(FixtureFactory::FIXTURE_CLIENT_FIRST_REDIRECT_URI, $response->getHeaderLine('Location')); + $queryData = $this->extractQueryDataFromUri($response->getHeaderLine('Location')); + $this->assertSame('invalid_scope', $queryData['error']); + $this->assertSame('The requested scope is invalid, unknown, or malformed', $queryData['message']); + $this->assertSame('Check the `non_existing` scope', $queryData['hint']); } public function testCodeRequestWithInvalidRedirectUri(): void @@ -453,8 +469,10 @@ public function testCodeRequestWithInvalidRedirectUri(): void $response = $this->handleAuthorizeRequest($request); // Response assertions. - $this->assertSame('invalid_client', $response['error']); - $this->assertSame('Client authentication failed', $response['message']); + $this->assertSame(401, $response->getStatusCode()); + $responseData = json_decode($response->getBody(), true); + $this->assertSame('invalid_client', $responseData['error']); + $this->assertSame('Client authentication failed', $responseData['message']); } public function testDeniedCodeRequest(): void @@ -467,9 +485,13 @@ public function testDeniedCodeRequest(): void $response = $this->handleAuthorizeRequest($request, false); // Response assertions. - $this->assertSame('access_denied', $response['error']); - $this->assertSame('The resource owner or authorization server denied the request.', $response['message']); - $this->assertSame('The user denied the request', $response['hint']); + $this->assertSame(302, $response->getStatusCode()); + $this->assertTrue($response->hasHeader('Location')); + $this->assertStringStartsWith(FixtureFactory::FIXTURE_CLIENT_FIRST_REDIRECT_URI, $response->getHeaderLine('Location')); + $queryData = $this->extractQueryDataFromUri($response->getHeaderLine('Location')); + $this->assertSame('access_denied', $queryData['error']); + $this->assertSame('The resource owner or authorization server denied the request.', $queryData['message']); + $this->assertSame('The user denied the request', $queryData['hint']); } public function testCodeRequestWithMissingClient(): void @@ -482,8 +504,10 @@ public function testCodeRequestWithMissingClient(): void $response = $this->handleAuthorizeRequest($request, false); // Response assertions. - $this->assertSame('invalid_client', $response['error']); - $this->assertSame('Client authentication failed', $response['message']); + $this->assertSame(401, $response->getStatusCode()); + $responseData = json_decode($response->getBody(), true); + $this->assertSame('invalid_client', $responseData['error']); + $this->assertSame('Client authentication failed', $responseData['message']); } public function testCodeRequestWithInactiveClient(): void @@ -496,8 +520,10 @@ public function testCodeRequestWithInactiveClient(): void $response = $this->handleAuthorizeRequest($request, false); // Response assertions. - $this->assertSame('invalid_client', $response['error']); - $this->assertSame('Client authentication failed', $response['message']); + $this->assertSame(401, $response->getStatusCode()); + $responseData = json_decode($response->getBody(), true); + $this->assertSame('invalid_client', $responseData['error']); + $this->assertSame('Client authentication failed', $responseData['message']); } public function testCodeRequestWithRestrictedGrantClient(): void @@ -510,8 +536,10 @@ public function testCodeRequestWithRestrictedGrantClient(): void $response = $this->handleAuthorizeRequest($request, false); // Response assertions. - $this->assertSame('invalid_client', $response['error']); - $this->assertSame('Client authentication failed', $response['message']); + $this->assertSame(401, $response->getStatusCode()); + $responseData = json_decode($response->getBody(), true); + $this->assertSame('invalid_client', $responseData['error']); + $this->assertSame('Client authentication failed', $responseData['message']); } public function testSuccessfulAuthorizationWithCode(): void From 6f9e8b0715b772cd08c4191131fd429afa6b865a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Fri, 8 Mar 2019 13:31:30 +0100 Subject: [PATCH 24/44] Rename test methods --- Tests/Integration/AbstractIntegrationTest.php | 4 +- Tests/Integration/AuthorizationServerTest.php | 68 +++++++++---------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/Tests/Integration/AbstractIntegrationTest.php b/Tests/Integration/AbstractIntegrationTest.php index 8485244a..ae403e47 100644 --- a/Tests/Integration/AbstractIntegrationTest.php +++ b/Tests/Integration/AbstractIntegrationTest.php @@ -188,7 +188,7 @@ protected function createAuthorizeRequest(?string $credentials, array $query = [ return new ServerRequest([], [], null, null, 'php://temp', $headers, [], $query, ''); } - protected function handleAuthorizationRequest(ServerRequestInterface $serverRequest): array + protected function handleTokenRequest(ServerRequestInterface $serverRequest): array { $response = new Response(); @@ -212,7 +212,7 @@ protected function handleResourceRequest(ServerRequestInterface $serverRequest): return $serverRequest; } - protected function handleAuthorizeRequest(ServerRequestInterface $serverRequest, $approved = true): ResponseInterface + protected function handleAuthorizationRequest(ServerRequestInterface $serverRequest, $approved = true): ResponseInterface { $response = new Response(); diff --git a/Tests/Integration/AuthorizationServerTest.php b/Tests/Integration/AuthorizationServerTest.php index 1448dfa5..b17a7084 100644 --- a/Tests/Integration/AuthorizationServerTest.php +++ b/Tests/Integration/AuthorizationServerTest.php @@ -17,7 +17,7 @@ public function testSuccessfulAuthorizationThroughHeaders(): void 'grant_type' => 'client_credentials', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Assert that we got something that looks like a normal response. $this->assertArrayHasKey('token_type', $response); @@ -31,7 +31,7 @@ public function testSuccessfulAuthorizationThroughBody(): void 'grant_type' => 'client_credentials', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Assert that we got something that looks like a normal response. $this->assertArrayHasKey('token_type', $response); @@ -43,7 +43,7 @@ public function testMissingAuthorizationCredentials(): void 'grant_type' => 'client_credentials', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_request', $response['error']); @@ -57,7 +57,7 @@ public function testInvalidAuthorizationCredentials(): void 'grant_type' => 'client_credentials', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_client', $response['error']); @@ -70,7 +70,7 @@ public function testMissingClient(): void 'grant_type' => 'client_credentials', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_client', $response['error']); @@ -83,7 +83,7 @@ public function testInactiveClient(): void 'grant_type' => 'client_credentials', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_client', $response['error']); @@ -96,7 +96,7 @@ public function testRestrictedGrantClient(): void 'grant_type' => 'client_credentials', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_client', $response['error']); @@ -109,7 +109,7 @@ public function testInvalidGrantType(): void 'grant_type' => 'non_existing', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('unsupported_grant_type', $response['error']); @@ -124,7 +124,7 @@ public function testInvalidScope(): void 'scope' => 'non_existing', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_scope', $response['error']); @@ -140,7 +140,7 @@ public function testValidClientCredentialsGrant(): void timecop_freeze(new DateTime()); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); timecop_return(); @@ -164,7 +164,7 @@ public function testValidClientCredentialsGrantWithScope(): void timecop_freeze(new DateTime()); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); timecop_return(); @@ -201,7 +201,7 @@ public function testValidPasswordGrant(): void timecop_freeze(new DateTime()); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); timecop_return(); @@ -233,7 +233,7 @@ public function testInvalidCredentialsPasswordGrant(): void 'password' => 'pass', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_credentials', $response['error']); @@ -247,7 +247,7 @@ public function testMissingUsernameFieldPasswordGrant(): void 'password' => 'pass', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_request', $response['error']); @@ -262,7 +262,7 @@ public function testMissingPasswordFieldPasswordGrant(): void 'username' => 'user', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_request', $response['error']); @@ -282,7 +282,7 @@ public function testValidRefreshGrant(): void timecop_freeze(new DateTime()); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); timecop_return(); @@ -313,7 +313,7 @@ public function testDifferentClientRefreshGrant(): void 'refresh_token' => TestHelper::generateEncryptedPayload($existingRefreshToken), ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_request', $response['error']); @@ -330,7 +330,7 @@ public function testExpiredRefreshGrant(): void 'refresh_token' => TestHelper::generateEncryptedPayload($existingRefreshToken), ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_request', $response['error']); @@ -347,7 +347,7 @@ public function testRevokedRefreshGrant(): void 'refresh_token' => TestHelper::generateEncryptedPayload($existingRefreshToken), ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_request', $response['error']); @@ -361,7 +361,7 @@ public function testMissingPayloadRefreshGrant(): void 'grant_type' => 'refresh_token', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_request', $response['error']); @@ -376,7 +376,7 @@ public function testInvalidPayloadRefreshGrant(): void 'refresh_token' => 'invalid', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_request', $response['error']); @@ -391,7 +391,7 @@ public function testSuccessfulCodeRequest(): void 'client_id' => 'foo', ]); - $response = $this->handleAuthorizeRequest($request); + $response = $this->handleAuthorizationRequest($request); // Response assertions. $this->assertSame(302, $response->getStatusCode()); @@ -409,7 +409,7 @@ public function testSuccessfulCodeRequestWithState(): void 'state' => 'quzbaz', ]); - $response = $this->handleAuthorizeRequest($request); + $response = $this->handleAuthorizationRequest($request); // Response assertions. $this->assertSame(302, $response->getStatusCode()); @@ -428,7 +428,7 @@ public function testSuccessfulCodeRequestWithRedirectUri(): void 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', ]); - $response = $this->handleAuthorizeRequest($request); + $response = $this->handleAuthorizationRequest($request); // Response assertions. $this->assertSame(302, $response->getStatusCode()); @@ -446,7 +446,7 @@ public function testCodeRequestWithInvalidScope(): void 'scope' => 'non_existing', ]); - $response = $this->handleAuthorizeRequest($request); + $response = $this->handleAuthorizationRequest($request); // Response assertions. $this->assertSame(302, $response->getStatusCode()); @@ -466,7 +466,7 @@ public function testCodeRequestWithInvalidRedirectUri(): void 'redirect_uri' => 'https://example.org/oauth2/other-uri', ]); - $response = $this->handleAuthorizeRequest($request); + $response = $this->handleAuthorizationRequest($request); // Response assertions. $this->assertSame(401, $response->getStatusCode()); @@ -482,7 +482,7 @@ public function testDeniedCodeRequest(): void 'client_id' => 'foo', ]); - $response = $this->handleAuthorizeRequest($request, false); + $response = $this->handleAuthorizationRequest($request, false); // Response assertions. $this->assertSame(302, $response->getStatusCode()); @@ -501,7 +501,7 @@ public function testCodeRequestWithMissingClient(): void 'client_id' => 'yolo', ]); - $response = $this->handleAuthorizeRequest($request, false); + $response = $this->handleAuthorizationRequest($request, false); // Response assertions. $this->assertSame(401, $response->getStatusCode()); @@ -517,7 +517,7 @@ public function testCodeRequestWithInactiveClient(): void 'client_id' => 'baz_inactive', ]); - $response = $this->handleAuthorizeRequest($request, false); + $response = $this->handleAuthorizationRequest($request, false); // Response assertions. $this->assertSame(401, $response->getStatusCode()); @@ -533,7 +533,7 @@ public function testCodeRequestWithRestrictedGrantClient(): void 'client_id' => 'qux_restricted', ]); - $response = $this->handleAuthorizeRequest($request, false); + $response = $this->handleAuthorizationRequest($request, false); // Response assertions. $this->assertSame(401, $response->getStatusCode()); @@ -552,7 +552,7 @@ public function testSuccessfulAuthorizationWithCode(): void 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); $accessToken = $this->getAccessToken($response['access_token']); $this->assertSame('Bearer', $response['token_type']); @@ -571,7 +571,7 @@ public function testFailedAuthorizationWithCodeForOtherClient(): void 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_request', $response['error']); @@ -589,7 +589,7 @@ public function testFailedAuthorizationWithExpiredCode(): void 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_request', $response['error']); @@ -607,7 +607,7 @@ public function testFailedAuthorizationWithInvalidRedirectUri(): void 'redirect_uri' => 'https://example.org/oauth2/other-uri', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_client', $response['error']); From 57fe8d2b818a8373d8df904ddfd4fa17046fff9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Fri, 8 Mar 2019 14:00:20 +0100 Subject: [PATCH 25/44] Update the AuthorizationRequestResolveEvent workflow --- Controller/AuthorizationController.php | 9 ++-- Event/AuthorizationRequestResolveEvent.php | 45 +++++++++---------- .../Acceptance/AuthorizationEndpointTest.php | 4 +- 3 files changed, 28 insertions(+), 30 deletions(-) diff --git a/Controller/AuthorizationController.php b/Controller/AuthorizationController.php index 2177a45b..b1fbedcc 100644 --- a/Controller/AuthorizationController.php +++ b/Controller/AuthorizationController.php @@ -46,18 +46,17 @@ public function indexAction(ServerRequestInterface $serverRequest): ResponseInte $authRequest = $this->server->validateAuthorizationRequest($serverRequest); $authRequest->setUser($this->getUserEntity()); + /** @var AuthorizationRequestResolveEvent $event */ $event = $this->eventDispatcher->dispatch( OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, new AuthorizationRequestResolveEvent($authRequest) ); - if (AuthorizationRequestResolveEvent::AUTHORIZATION_PENDING === $event->getAuhorizationResolution()) { - return $serverResponse->withStatus(302)->withHeader('Location', $event->getResolutionUri()); + if ($event->hasResponse()) { + return $event->getResponse(); } - if (AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED === $event->getAuhorizationResolution()) { - $authRequest->setAuthorizationApproved(true); - } + $authRequest->setAuthorizationApproved($event->getAuhorizationResolution()); return $this->server->completeAuthorizationRequest($authRequest, $serverResponse); } catch (OAuthServerException $e) { diff --git a/Event/AuthorizationRequestResolveEvent.php b/Event/AuthorizationRequestResolveEvent.php index be8ed29b..7b9e08d0 100644 --- a/Event/AuthorizationRequestResolveEvent.php +++ b/Event/AuthorizationRequestResolveEvent.php @@ -7,18 +7,13 @@ use League\OAuth2\Server\Entities\UserEntityInterface; use League\OAuth2\Server\RequestTypes\AuthorizationRequest; use LogicException; +use Psr\Http\Message\ResponseInterface; use Symfony\Component\EventDispatcher\Event; final class AuthorizationRequestResolveEvent extends Event { - public const AUTHORIZATION_PENDING = 0; - public const AUTHORIZATION_APPROVED = 1; - public const AUTHORIZATION_DENIED = 2; - - public const ALLOWED_RESOLUTIONS = [ - self::AUTHORIZATION_APPROVED, - self::AUTHORIZATION_DENIED, - ]; + public const AUTHORIZATION_APPROVED = true; + public const AUTHORIZATION_DENIED = false; /** * @var AuthorizationRequest @@ -26,46 +21,48 @@ final class AuthorizationRequestResolveEvent extends Event private $authorizationRequest; /** - * @var ?string + * @var ?ResponseInterface */ - private $resolutionUri; + private $response; /** - * @var int + * @var bool */ - private $authorizationResolution; + private $authorizationResolution = self::AUTHORIZATION_DENIED; public function __construct(AuthorizationRequest $authorizationRequest) { $this->authorizationRequest = $authorizationRequest; - $this->authorizationResolution = self::AUTHORIZATION_PENDING; } - public function getAuhorizationResolution(): int + public function getAuhorizationResolution(): bool { return $this->authorizationResolution; } - public function resolveAuthorization(int $authorizationResolution): void + public function resolveAuthorization(bool $authorizationResolution): void { - if (!\in_array($authorizationResolution, self::ALLOWED_RESOLUTIONS, true)) { - throw new LogicException('The given resolution code is not allowed.'); - } $this->authorizationResolution = $authorizationResolution; + $this->response = null; + } + + public function hasResponse(): bool + { + return $this->response instanceof ResponseInterface; } - public function getResolutionUri(): string + public function getResponse(): ResponseInterface { - if (null === $this->resolutionUri) { - throw new LogicException('There is no resolution URI. If the authorization request is not approved nor denied, a resolution URI should be provided'); + if (!$this->hasResponse()) { + throw new LogicException('There is no response. You should call "hasResponse" to check if the response exists.'); } - return $this->resolutionUri; + return $this->response; } - public function setResolutionUri(string $resolutionUri): void + public function setResponse(ResponseInterface $response): void { - $this->resolutionUri = $resolutionUri; + $this->response = $response; } public function getGrantTypeId(): string diff --git a/Tests/Acceptance/AuthorizationEndpointTest.php b/Tests/Acceptance/AuthorizationEndpointTest.php index 46e0653c..0060d182 100644 --- a/Tests/Acceptance/AuthorizationEndpointTest.php +++ b/Tests/Acceptance/AuthorizationEndpointTest.php @@ -6,6 +6,7 @@ use Trikoder\Bundle\OAuth2Bundle\Event\AuthorizationRequestResolveEvent; use Trikoder\Bundle\OAuth2Bundle\OAuth2Events; use Trikoder\Bundle\OAuth2Bundle\Tests\Fixtures\FixtureFactory; +use Zend\Diactoros\Response; final class AuthorizationEndpointTest extends AbstractAcceptanceTest { @@ -51,7 +52,8 @@ public function testCodeRequestRedirectToResolutionUri() ->getContainer() ->get('event_dispatcher') ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event) { - $event->setResolutionUri('/authorize/consent'); + $response = (new Response())->withStatus(302)->withHeader('Location', '/authorize/consent'); + $event->setResponse($response); }); timecop_freeze(new DateTime()); From fbafd72e12d324890783d07b749523e8b6eeea16 Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Wed, 10 Apr 2019 20:15:09 +0200 Subject: [PATCH 26/44] configure authentication listener properly --- DependencyInjection/Configuration.php | 16 +++++++++---- .../TrikoderOAuth2Extension.php | 7 +++--- ...orizationRequestAuthenticationListener.php | 24 +++++++++---------- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 65330fe1..88fe7885 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -5,6 +5,7 @@ use Symfony\Component\Config\Definition\Builder\NodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; +use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationDecision\UserConsentDecisionStrategy; final class Configuration implements ConfigurationInterface { @@ -27,6 +28,7 @@ public function getConfigTreeBuilder() private function createAuthorizationServerNode(): NodeDefinition { + /** @var TreeBuilder $treeBuilder */ $treeBuilder = $this->getWrappedTreeBuilder('authorization_server'); $node = $treeBuilder->getRootNode(); @@ -59,10 +61,10 @@ private function createAuthorizationServerNode(): NodeDefinition ->cannotBeEmpty() ->defaultValue('PT10M') ->end() - ->enumNode('authorization_strategy') - ->info("What strategy should be used to authorize user.\nAvailable options are: user_consent - let user authorize the client request, always_allow - silently authorizes all client requests") - ->values(['user_consent', 'always_allow']) - ->defaultValue('user_consent') + ->scalarNode('authorization_strategy') + ->isRequired() + ->info("What strategy should be used to authorize user.\nService must implement AuthorizationDecisionStrategy interface") + ->defaultValue(UserConsentDecisionStrategy::class) ->end() ->end() ; @@ -136,6 +138,7 @@ private function createPersistenceNode(): NodeDefinition private function createOpenIDConnectNode(): NodeDefinition { + /** @var TreeBuilder $treeBuilder */ $treeBuilder = $this->getWrappedTreeBuilder('openid_connect'); $node = $treeBuilder->getRootNode(); @@ -147,6 +150,11 @@ private function createOpenIDConnectNode(): NodeDefinition ->addDefaultsIfNotSet() ->children() ->booleanNode('enabled')->defaultNull()->end() + ->scalarNode('login_route') + ->isRequired() + ->info('Login route to redirect to unauthenticated users') + ->defaultValue('app_login') + ->end() ->end() ; diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 1ace0ce0..b643fb28 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -228,14 +228,14 @@ private function configureOpenIDConnect(ContainerBuilder $container, array $open ; $container ->setDefinition( - 'trikoder.oauth2.event_listener.require_authentication.', - $this->cerateAuthorizationRequestAuthenticationListenerDefinition() + 'trikoder.oauth2.event_listener.require_authentication', + $this->createAuthorizationRequestAuthenticationListenerDefinition($openid_connect['login_route']) ) ; } } - private function cerateAuthorizationRequestAuthenticationListenerDefinition(): Definition + private function createAuthorizationRequestAuthenticationListenerDefinition(string $loginRoute): Definition { return (new Definition(AuthorizationRequestAuthenticationListener::class)) ->setArguments([ @@ -243,6 +243,7 @@ private function cerateAuthorizationRequestAuthenticationListenerDefinition(): D new Reference('session'), new Reference('request_stack'), new Reference('router'), + $loginRoute ]) ->addTag('kernel.event_listener', [ 'event' => 'trikoder.oauth2.authorization_request_resolve', diff --git a/Event/Listener/AuthorizationRequestAuthenticationListener.php b/Event/Listener/AuthorizationRequestAuthenticationListener.php index e015ec8d..068ad9c4 100644 --- a/Event/Listener/AuthorizationRequestAuthenticationListener.php +++ b/Event/Listener/AuthorizationRequestAuthenticationListener.php @@ -2,6 +2,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Event\Listener; +use Symfony\Bundle\SecurityBundle\Security\FirewallMap; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; @@ -43,35 +44,31 @@ class AuthorizationRequestAuthenticationListener implements AuthorizationEventLi private $urlGenerator; /** - * @var string + * @var FirewallMap */ - private $loginRoute; + private $firewallMap; /** - * AuthorizationRequestAuthenticationListener constructor. - * @param AuthorizationCheckerInterface $authorizationChecker - * @param SessionInterface $session - * @param RequestStack $requestStack - * @param UrlGeneratorInterface $urlGenerator - * @param string $loginRoute + * @var string */ + private $loginRoute; + public function __construct( AuthorizationCheckerInterface $authorizationChecker, SessionInterface $session, RequestStack $requestStack, UrlGeneratorInterface $urlGenerator, - string $loginRoute = 'app_login' + FirewallMap $firewallMap, + string $loginRoute ) { $this->authorizationChecker = $authorizationChecker; $this->session = $session; $this->requestStack = $requestStack; $this->urlGenerator = $urlGenerator; + $this->firewallMap = $firewallMap; $this->loginRoute = $loginRoute; } - /** - * @param AuthorizationRequestResolveEvent $event - */ public function onAuthorizationRequest(AuthorizationRequestResolveEvent $event): void { if (null === $request = $this->requestStack->getMasterRequest()) { @@ -79,7 +76,8 @@ public function onAuthorizationRequest(AuthorizationRequestResolveEvent $event): } if (!$this->authorizationChecker->isGranted('IS_AUTHENTICATED_REMEMBERED')) { - $this->saveTargetPath($this->session, 'main', $request->getUri()); + $firewallConfig = $this->firewallMap->getFirewallConfig($request); + $this->saveTargetPath($this->session, $firewallConfig->getProvider(), $request->getUri()); $loginUrl = $this->urlGenerator->generate($this->loginRoute); $event->setResponse(new Response(null, 302, ['Location' => $loginUrl])); From 882eeb3ad8c60c5f7c9c267e4d82a83990deefe9 Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Wed, 10 Apr 2019 20:20:07 +0200 Subject: [PATCH 27/44] use PSR response interface, reorder methods --- Event/AuthorizationRequestResolveEvent.php | 42 +++++++++++----------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/Event/AuthorizationRequestResolveEvent.php b/Event/AuthorizationRequestResolveEvent.php index a64730d9..eebb9505 100644 --- a/Event/AuthorizationRequestResolveEvent.php +++ b/Event/AuthorizationRequestResolveEvent.php @@ -7,9 +7,9 @@ use League\OAuth2\Server\Entities\UserEntityInterface; use League\OAuth2\Server\RequestTypes\AuthorizationRequest; use LogicException; +use Psr\Http\Message\ResponseInterface; use Symfony\Component\EventDispatcher\Event; use Trikoder\Bundle\OAuth2Bundle\League\Entity\User; -use Zend\Diactoros\Response; /** * Class AuthorizationRequestResolveEvent @@ -24,7 +24,7 @@ final class AuthorizationRequestResolveEvent extends Event private $authorizationRequest; /** - * @var Response + * @var null|ResponseInterface */ private $response; @@ -33,6 +33,25 @@ public function __construct(AuthorizationRequest $authorizationRequest) $this->authorizationRequest = $authorizationRequest; } + public function getResponse(): ?ResponseInterface + { + if (!$this->hasResponse()) { + throw new LogicException('There is no response. You should call "hasResponse" to check if the response exists.'); + } + + return $this->response; + } + + public function setResponse(ResponseInterface $response): void + { + $this->response = $response; + } + + public function hasResponse(): bool + { + return $this->response !== null; + } + public function getGrantTypeId(): string { return $this->authorizationRequest->getGrantTypeId(); @@ -90,23 +109,4 @@ public function getCodeChallengeMethod(): string { return $this->authorizationRequest->getCodeChallengeMethod(); } - - public function getResponse(): ?Response - { - if (!$this->hasResponse()) { - throw new LogicException('There is no response. You should call "hasResponse" to check if the response exists.'); - } - - return $this->response; - } - - public function setResponse(Response $response): void - { - $this->response = $response; - } - - public function hasResponse(): bool - { - return $this->response !== null; - } } From 7b5cf26dbc21f65191f1a3265564132f01c16fc0 Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Wed, 10 Apr 2019 20:36:56 +0200 Subject: [PATCH 28/44] configure consent strategy correctly --- DependencyInjection/Configuration.php | 5 +++++ DependencyInjection/TrikoderOAuth2Extension.php | 14 +++++++------- .../UserConsentDecisionStrategy.php | 2 +- Resources/config/services.xml | 6 +++++- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 88fe7885..72128936 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -66,6 +66,11 @@ private function createAuthorizationServerNode(): NodeDefinition ->info("What strategy should be used to authorize user.\nService must implement AuthorizationDecisionStrategy interface") ->defaultValue(UserConsentDecisionStrategy::class) ->end() + ->scalarNode('consent_route') + ->isRequired() + ->info('The route to redirect the user to when the user consent is required for authorization') + ->defaultValue('oauth2_consent') + ->end() ->end() ; diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index b643fb28..a3af8aa5 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -18,6 +18,7 @@ use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\Scope as ScopeType; use Trikoder\Bundle\OAuth2Bundle\Event\Listener\AuthorizationRequestAuthenticationListener; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationDecision\UserConsentDecisionStrategy; use Trikoder\Bundle\OAuth2Bundle\Model\Scope as ScopeModel; final class TrikoderOAuth2Extension extends Extension implements PrependExtensionInterface @@ -97,7 +98,7 @@ private function configureAuthorizationServer(ContainerBuilder $container, array ]); $this->configureGrants($container, $config); - $this->configureAuthorizationStrategy($container, $config['authorization_strategy']); + $this->configureAuthorizationStrategy($container, $config['authorization_strategy'], $config['consent_route']); } private function configureGrants(ContainerBuilder $container, array $config): void @@ -210,13 +211,12 @@ private function configureScopes(ContainerBuilder $container, array $scopes): vo } } - private function configureAuthorizationStrategy(ContainerBuilder $container, string $authorizationStrategy) + private function configureAuthorizationStrategy(ContainerBuilder $container, string $authorizationStrategy, string $consentRoute) { - if ($authorizationStrategy == 'always_allow') { - $container - ->getDefinition('trikoder.oauth2.event_listener.authorization.decision') - ->replaceArgument(0, new Reference('trikoder.oauth2.authorization_decision_strategy.always_allow')); - } + $container->getDefinition(UserConsentDecisionStrategy::class)->replaceArgument(3, $consentRoute); + $container + ->getDefinition('trikoder.oauth2.event_listener.authorization.decision') + ->replaceArgument(0, new Reference($authorizationStrategy)); } private function configureOpenIDConnect(ContainerBuilder $container, array $openid_connect): void diff --git a/Model/AuthorizationDecision/UserConsentDecisionStrategy.php b/Model/AuthorizationDecision/UserConsentDecisionStrategy.php index 93a0d531..2b6acb10 100644 --- a/Model/AuthorizationDecision/UserConsentDecisionStrategy.php +++ b/Model/AuthorizationDecision/UserConsentDecisionStrategy.php @@ -59,7 +59,7 @@ public function __construct( UriSigner $uriSigner, RequestStack $requestStack, UrlGeneratorInterface $urlGenerator, - string $consentApprovalRoute = 'oauth2_consent' + string $consentApprovalRoute ) { $this->consentApprovalRoute = $consentApprovalRoute; $this->uriSigner = $uriSigner; diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 37cdc3b9..a98d4929 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -90,7 +90,7 @@ - + @@ -104,11 +104,15 @@ + + + + From 475ffb22a546580fa89ff6594064c13e866a9a3f Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Wed, 10 Apr 2019 20:15:09 +0200 Subject: [PATCH 29/44] configure authentication listener properly # Conflicts: # DependencyInjection/Configuration.php # DependencyInjection/TrikoderOAuth2Extension.php --- DependencyInjection/Configuration.php | 10 ++++---- .../TrikoderOAuth2Extension.php | 1 - ...orizationRequestAuthenticationListener.php | 24 +++++++++---------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 8834ed51..9fe57b88 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -5,6 +5,7 @@ use Symfony\Component\Config\Definition\Builder\NodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; +use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationDecision\UserConsentDecisionStrategy; final class Configuration implements ConfigurationInterface { @@ -26,6 +27,7 @@ public function getConfigTreeBuilder() private function createAuthorizationServerNode(): NodeDefinition { + /** @var TreeBuilder $treeBuilder */ $treeBuilder = $this->getWrappedTreeBuilder('authorization_server'); $node = $treeBuilder->getRootNode(); @@ -58,10 +60,10 @@ private function createAuthorizationServerNode(): NodeDefinition ->cannotBeEmpty() ->defaultValue('PT10M') ->end() - ->enumNode('authorization_strategy') - ->info("What strategy should be used to authorize user.\nAvailable options are: user_consent - let user authorize the client request, always_allow - silently authorizes all client requests") - ->values(['user_consent', 'always_allow']) - ->defaultValue('user_consent') + ->scalarNode('authorization_strategy') + ->isRequired() + ->info("What strategy should be used to authorize user.\nService must implement AuthorizationDecisionStrategy interface") + ->defaultValue(UserConsentDecisionStrategy::class) ->end() ->end() ; diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 1a7052ab..b1eb5d75 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -16,7 +16,6 @@ use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\Grant as GrantType; use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\RedirectUri as RedirectUriType; use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\Scope as ScopeType; -use Trikoder\Bundle\OAuth2Bundle\Event\Listener\AuthorizationRequestAuthenticationListener; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\Scope as ScopeModel; diff --git a/Event/Listener/AuthorizationRequestAuthenticationListener.php b/Event/Listener/AuthorizationRequestAuthenticationListener.php index e015ec8d..068ad9c4 100644 --- a/Event/Listener/AuthorizationRequestAuthenticationListener.php +++ b/Event/Listener/AuthorizationRequestAuthenticationListener.php @@ -2,6 +2,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Event\Listener; +use Symfony\Bundle\SecurityBundle\Security\FirewallMap; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; @@ -43,35 +44,31 @@ class AuthorizationRequestAuthenticationListener implements AuthorizationEventLi private $urlGenerator; /** - * @var string + * @var FirewallMap */ - private $loginRoute; + private $firewallMap; /** - * AuthorizationRequestAuthenticationListener constructor. - * @param AuthorizationCheckerInterface $authorizationChecker - * @param SessionInterface $session - * @param RequestStack $requestStack - * @param UrlGeneratorInterface $urlGenerator - * @param string $loginRoute + * @var string */ + private $loginRoute; + public function __construct( AuthorizationCheckerInterface $authorizationChecker, SessionInterface $session, RequestStack $requestStack, UrlGeneratorInterface $urlGenerator, - string $loginRoute = 'app_login' + FirewallMap $firewallMap, + string $loginRoute ) { $this->authorizationChecker = $authorizationChecker; $this->session = $session; $this->requestStack = $requestStack; $this->urlGenerator = $urlGenerator; + $this->firewallMap = $firewallMap; $this->loginRoute = $loginRoute; } - /** - * @param AuthorizationRequestResolveEvent $event - */ public function onAuthorizationRequest(AuthorizationRequestResolveEvent $event): void { if (null === $request = $this->requestStack->getMasterRequest()) { @@ -79,7 +76,8 @@ public function onAuthorizationRequest(AuthorizationRequestResolveEvent $event): } if (!$this->authorizationChecker->isGranted('IS_AUTHENTICATED_REMEMBERED')) { - $this->saveTargetPath($this->session, 'main', $request->getUri()); + $firewallConfig = $this->firewallMap->getFirewallConfig($request); + $this->saveTargetPath($this->session, $firewallConfig->getProvider(), $request->getUri()); $loginUrl = $this->urlGenerator->generate($this->loginRoute); $event->setResponse(new Response(null, 302, ['Location' => $loginUrl])); From afc1b5045608dcffd6c975d5a5fef5a5c6e1a14b Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Wed, 10 Apr 2019 20:20:07 +0200 Subject: [PATCH 30/44] use PSR response interface, reorder methods --- Event/AuthorizationRequestResolveEvent.php | 42 +++++++++++----------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/Event/AuthorizationRequestResolveEvent.php b/Event/AuthorizationRequestResolveEvent.php index a64730d9..eebb9505 100644 --- a/Event/AuthorizationRequestResolveEvent.php +++ b/Event/AuthorizationRequestResolveEvent.php @@ -7,9 +7,9 @@ use League\OAuth2\Server\Entities\UserEntityInterface; use League\OAuth2\Server\RequestTypes\AuthorizationRequest; use LogicException; +use Psr\Http\Message\ResponseInterface; use Symfony\Component\EventDispatcher\Event; use Trikoder\Bundle\OAuth2Bundle\League\Entity\User; -use Zend\Diactoros\Response; /** * Class AuthorizationRequestResolveEvent @@ -24,7 +24,7 @@ final class AuthorizationRequestResolveEvent extends Event private $authorizationRequest; /** - * @var Response + * @var null|ResponseInterface */ private $response; @@ -33,6 +33,25 @@ public function __construct(AuthorizationRequest $authorizationRequest) $this->authorizationRequest = $authorizationRequest; } + public function getResponse(): ?ResponseInterface + { + if (!$this->hasResponse()) { + throw new LogicException('There is no response. You should call "hasResponse" to check if the response exists.'); + } + + return $this->response; + } + + public function setResponse(ResponseInterface $response): void + { + $this->response = $response; + } + + public function hasResponse(): bool + { + return $this->response !== null; + } + public function getGrantTypeId(): string { return $this->authorizationRequest->getGrantTypeId(); @@ -90,23 +109,4 @@ public function getCodeChallengeMethod(): string { return $this->authorizationRequest->getCodeChallengeMethod(); } - - public function getResponse(): ?Response - { - if (!$this->hasResponse()) { - throw new LogicException('There is no response. You should call "hasResponse" to check if the response exists.'); - } - - return $this->response; - } - - public function setResponse(Response $response): void - { - $this->response = $response; - } - - public function hasResponse(): bool - { - return $this->response !== null; - } } From 6c27e70dc1622a945c62c1bba7fb448a9b383e0a Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Wed, 10 Apr 2019 20:36:56 +0200 Subject: [PATCH 31/44] configure consent strategy correctly --- DependencyInjection/Configuration.php | 5 +++++ DependencyInjection/TrikoderOAuth2Extension.php | 14 +++++++------- .../UserConsentDecisionStrategy.php | 2 +- Resources/config/services.xml | 6 +++++- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 9fe57b88..eb95425e 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -65,6 +65,11 @@ private function createAuthorizationServerNode(): NodeDefinition ->info("What strategy should be used to authorize user.\nService must implement AuthorizationDecisionStrategy interface") ->defaultValue(UserConsentDecisionStrategy::class) ->end() + ->scalarNode('consent_route') + ->isRequired() + ->info('The route to redirect the user to when the user consent is required for authorization') + ->defaultValue('oauth2_consent') + ->end() ->end() ; diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index b1eb5d75..882e43a0 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -17,6 +17,7 @@ use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\RedirectUri as RedirectUriType; use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\Scope as ScopeType; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationDecision\UserConsentDecisionStrategy; use Trikoder\Bundle\OAuth2Bundle\Model\Scope as ScopeModel; final class TrikoderOAuth2Extension extends Extension implements PrependExtensionInterface @@ -95,7 +96,7 @@ private function configureAuthorizationServer(ContainerBuilder $container, array ]); $this->configureGrants($container, $config); - $this->configureAuthorizationStrategy($container, $config['authorization_strategy']); + $this->configureAuthorizationStrategy($container, $config['authorization_strategy'], $config['consent_route']); } private function configureGrants(ContainerBuilder $container, array $config): void @@ -208,12 +209,11 @@ private function configureScopes(ContainerBuilder $container, array $scopes): vo } } - private function configureAuthorizationStrategy(ContainerBuilder $container, string $authorizationStrategy) + private function configureAuthorizationStrategy(ContainerBuilder $container, string $authorizationStrategy, string $consentRoute) { - if ($authorizationStrategy == 'always_allow') { - $container - ->getDefinition('trikoder.oauth2.event_listener.authorization.decision') - ->replaceArgument(0, new Reference('trikoder.oauth2.authorization_decision_strategy.always_allow')); - } + $container->getDefinition(UserConsentDecisionStrategy::class)->replaceArgument(3, $consentRoute); + $container + ->getDefinition('trikoder.oauth2.event_listener.authorization.decision') + ->replaceArgument(0, new Reference($authorizationStrategy)); } } diff --git a/Model/AuthorizationDecision/UserConsentDecisionStrategy.php b/Model/AuthorizationDecision/UserConsentDecisionStrategy.php index 93a0d531..2b6acb10 100644 --- a/Model/AuthorizationDecision/UserConsentDecisionStrategy.php +++ b/Model/AuthorizationDecision/UserConsentDecisionStrategy.php @@ -59,7 +59,7 @@ public function __construct( UriSigner $uriSigner, RequestStack $requestStack, UrlGeneratorInterface $urlGenerator, - string $consentApprovalRoute = 'oauth2_consent' + string $consentApprovalRoute ) { $this->consentApprovalRoute = $consentApprovalRoute; $this->uriSigner = $uriSigner; diff --git a/Resources/config/services.xml b/Resources/config/services.xml index a1530af0..3598fe72 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -90,7 +90,7 @@ - + @@ -104,11 +104,15 @@ + + + + From 4527719ad58ad2d54a51a944dab3ced70dbb3c6a Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Wed, 10 Apr 2019 21:50:58 +0200 Subject: [PATCH 32/44] reordered methods --- Event/AuthorizationRequestResolveEvent.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Event/AuthorizationRequestResolveEvent.php b/Event/AuthorizationRequestResolveEvent.php index eebb9505..e47aad1e 100644 --- a/Event/AuthorizationRequestResolveEvent.php +++ b/Event/AuthorizationRequestResolveEvent.php @@ -33,6 +33,11 @@ public function __construct(AuthorizationRequest $authorizationRequest) $this->authorizationRequest = $authorizationRequest; } + public function hasResponse(): bool + { + return $this->response !== null; + } + public function getResponse(): ?ResponseInterface { if (!$this->hasResponse()) { @@ -47,11 +52,6 @@ public function setResponse(ResponseInterface $response): void $this->response = $response; } - public function hasResponse(): bool - { - return $this->response !== null; - } - public function getGrantTypeId(): string { return $this->authorizationRequest->getGrantTypeId(); From f385bd10a42c562c89bb05373ceccfb194abd8c1 Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Thu, 9 May 2019 12:12:43 +0200 Subject: [PATCH 33/44] get definition by id instead of alias --- DependencyInjection/TrikoderOAuth2Extension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 2716c429..2c014d97 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -212,7 +212,7 @@ private function configureScopes(ContainerBuilder $container, array $scopes): vo private function configureAuthorizationStrategy(ContainerBuilder $container, string $authorizationStrategy, string $consentRoute) { - $container->getDefinition(UserConsentDecisionStrategy::class)->replaceArgument(3, $consentRoute); + $container->getDefinition('trikoder.oauth2.authorization_decision_strategy.user_consent')->replaceArgument(3, $consentRoute); $container ->getDefinition('trikoder.oauth2.event_listener.authorization.decision') ->replaceArgument(0, new Reference($authorizationStrategy)); From 5348443512d1e14624097f29dca67f6a9fed419b Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Fri, 10 May 2019 16:14:22 +0200 Subject: [PATCH 34/44] Made authentication listener extendable, fixed service configurations --- .../TrikoderOAuth2Extension.php | 28 +++---------------- ...orizationRequestAuthenticationListener.php | 28 +++++++++++-------- Resources/config/services.xml | 17 ++++++----- 3 files changed, 30 insertions(+), 43 deletions(-) diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 2c014d97..026e9a7f 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -7,6 +7,7 @@ use LogicException; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Extension\Extension; @@ -16,8 +17,8 @@ use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\Grant as GrantType; use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\RedirectUri as RedirectUriType; use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\Scope as ScopeType; +use Trikoder\Bundle\OAuth2Bundle\Event\Listener\AuthorizationRequestAuthenticationListener; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; -use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationDecision\UserConsentDecisionStrategy; use Trikoder\Bundle\OAuth2Bundle\Model\Scope as ScopeModel; final class TrikoderOAuth2Extension extends Extension implements PrependExtensionInterface @@ -226,30 +227,9 @@ private function configureOpenIDConnect(ContainerBuilder $container, array $open ->setArgument(5, new Reference('openid_connect_server.id_token_response')) ; $container - ->setDefinition( - 'trikoder.oauth2.event_listener.require_authentication', - $this->createAuthorizationRequestAuthenticationListenerDefinition($openid_connect['login_route']) - ) + ->getDefinition('trikoder.oauth2.event_listener.authorization.authentication') + ->setArgument(5, $openid_connect['login_route']) ; } } - - private function createAuthorizationRequestAuthenticationListenerDefinition(string $loginRoute): Definition - { - return (new Definition(AuthorizationRequestAuthenticationListener::class)) - ->setArguments([ - new Reference('security.authorization_checker'), - new Reference('session'), - new Reference('request_stack'), - new Reference('router'), - $loginRoute - ]) - ->addTag('kernel.event_listener', [ - 'event' => 'trikoder.oauth2.authorization_request_resolve', - 'method' => 'onAuthorizationRequest', - 'priority' => 300 - ]) - ; - - } } diff --git a/Event/Listener/AuthorizationRequestAuthenticationListener.php b/Event/Listener/AuthorizationRequestAuthenticationListener.php index 068ad9c4..c09d879e 100644 --- a/Event/Listener/AuthorizationRequestAuthenticationListener.php +++ b/Event/Listener/AuthorizationRequestAuthenticationListener.php @@ -9,7 +9,7 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Http\Util\TargetPathTrait; use Trikoder\Bundle\OAuth2Bundle\Event\AuthorizationRequestResolveEvent; -use Zend\Diactoros\Response; +use Zend\Diactoros\Response\RedirectResponse; /** * Class AuthorizationRequestAuthenticationListener @@ -34,24 +34,24 @@ class AuthorizationRequestAuthenticationListener implements AuthorizationEventLi private $session; /** - * @var RequestStack + * @var FirewallMap */ - private $requestStack; + private $firewallMap; /** - * @var UrlGeneratorInterface + * @var RequestStack */ - private $urlGenerator; + protected $requestStack; /** - * @var FirewallMap + * @var UrlGeneratorInterface */ - private $firewallMap; + protected $urlGenerator; /** * @var string */ - private $loginRoute; + protected $loginRoute; public function __construct( AuthorizationCheckerInterface $authorizationChecker, @@ -59,7 +59,7 @@ public function __construct( RequestStack $requestStack, UrlGeneratorInterface $urlGenerator, FirewallMap $firewallMap, - string $loginRoute + string $loginRoute = 'app_login' ) { $this->authorizationChecker = $authorizationChecker; $this->session = $session; @@ -78,9 +78,13 @@ public function onAuthorizationRequest(AuthorizationRequestResolveEvent $event): if (!$this->authorizationChecker->isGranted('IS_AUTHENTICATED_REMEMBERED')) { $firewallConfig = $this->firewallMap->getFirewallConfig($request); $this->saveTargetPath($this->session, $firewallConfig->getProvider(), $request->getUri()); - - $loginUrl = $this->urlGenerator->generate($this->loginRoute); - $event->setResponse(new Response(null, 302, ['Location' => $loginUrl])); + $this->setResponse($event); } } + + protected function setResponse(AuthorizationRequestResolveEvent $event): void + { + $loginUrl = $this->urlGenerator->generate($this->loginRoute); + $event->setResponse(new RedirectResponse($loginUrl)); + } } diff --git a/Resources/config/services.xml b/Resources/config/services.xml index a98d4929..65ff9f5f 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -94,13 +94,16 @@ - - - - - - - + + + + + + + + + + From f48d907cfee72700e0666b158f37f91e104db016 Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Tue, 21 May 2019 22:37:48 +0200 Subject: [PATCH 35/44] make properties protected to make them accesible for subclasses --- .../Listener/AuthorizationRequestAuthenticationListener.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Event/Listener/AuthorizationRequestAuthenticationListener.php b/Event/Listener/AuthorizationRequestAuthenticationListener.php index c09d879e..4c23cad1 100644 --- a/Event/Listener/AuthorizationRequestAuthenticationListener.php +++ b/Event/Listener/AuthorizationRequestAuthenticationListener.php @@ -26,17 +26,17 @@ class AuthorizationRequestAuthenticationListener implements AuthorizationEventLi /** * @var AuthorizationCheckerInterface */ - private $authorizationChecker; + protected $authorizationChecker; /** * @var SessionInterface */ - private $session; + protected $session; /** * @var FirewallMap */ - private $firewallMap; + protected $firewallMap; /** * @var RequestStack From 79cc5cf3afdbb51520f9d514551709e59370fffd Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Mon, 27 May 2019 09:35:27 +0200 Subject: [PATCH 36/44] adds nonce support --- Grant/AuthCodeGrant.php | 55 +++++++++++++++++++ League/Repository/AuthCodeRepository.php | 20 +++++++ Model/AuthorizationCode.php | 17 ++++++ OpenIDConnect/IdTokenResponse.php | 29 ++++++++++ .../doctrine/model/AuthorizationCode.orm.xml | 1 + Resources/config/services.xml | 4 +- 6 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 Grant/AuthCodeGrant.php create mode 100644 OpenIDConnect/IdTokenResponse.php diff --git a/Grant/AuthCodeGrant.php b/Grant/AuthCodeGrant.php new file mode 100644 index 00000000..b56b9ed4 --- /dev/null +++ b/Grant/AuthCodeGrant.php @@ -0,0 +1,55 @@ +nonce = $this->getQueryStringParameter('nonce', $request, null); + + return $authorizationRequest; + } + + protected function issueAuthCode(DateInterval $authCodeTTL, ClientEntityInterface $client, $userIdentifier, $redirectUri, array $scopes = []) + { + $autCode = parent::issueAuthCode($authCodeTTL, $client, $userIdentifier, $redirectUri, $scopes); + + if ($this->nonce !== null) { + $this->authCodeRepository->updateWithNonce($autCode, $this->nonce); + } + + return $autCode; + } + + public function respondToAccessTokenRequest(ServerRequestInterface $request, ResponseTypeInterface $responseType, DateInterval $accessTokenTTL) + { + $response = parent::respondToAccessTokenRequest($request, $responseType, $accessTokenTTL); + + if ($response instanceof IdTokenResponse) { + $encryptedAuthCode = $this->getRequestParameter('code', $request, null); + $authCodePayload = json_decode($this->decrypt($encryptedAuthCode)); + + $nonce = $this->authCodeRepository->getNonce($authCodePayload->auth_code_id); + $response->setNonce($nonce); + } + + return $response; + } +} diff --git a/League/Repository/AuthCodeRepository.php b/League/Repository/AuthCodeRepository.php index e8cbc51f..bb829c67 100644 --- a/League/Repository/AuthCodeRepository.php +++ b/League/Repository/AuthCodeRepository.php @@ -62,6 +62,26 @@ public function persistNewAuthCode(AuthCodeEntityInterface $authCode) $this->authorizationCodeManager->save($authorizationCode); } + public function updateWithNonce(AuthCodeEntityInterface $authCode, string $nonce) + { + /** @var AuthorizationCode $authorizationCode */ + $authorizationCode = $this->authorizationCodeManager->find($authCode->getIdentifier()); + + if (null === $authorizationCode) { + throw new \LogicException('You cant update code that wasnt\'t persisted'); + } + + $authorizationCode->setNonce($nonce); + + $this->authorizationCodeManager->save($authorizationCode); + } + + public function getNonce(string $authCodeIdentifier) + { + $authCode = $this->authorizationCodeManager->find($authCodeIdentifier); + return $authCode->getNonce(); + } + /** * {@inheritdoc} */ diff --git a/Model/AuthorizationCode.php b/Model/AuthorizationCode.php index 82eb6236..5310bcb2 100644 --- a/Model/AuthorizationCode.php +++ b/Model/AuthorizationCode.php @@ -36,6 +36,9 @@ class AuthorizationCode */ private $revoked = false; + /** @var string|null */ + private $nonce; + public function __construct( string $identifier, DateTime $expiry, @@ -94,4 +97,18 @@ public function revoke(): self return $this; } + + public function getNonce(): ?string + { + return $this->nonce; + } + + public function setNonce(string $nonce): self + { + if ($this->nonce === null) { + $this->nonce = $nonce; + } + + return $this; + } } diff --git a/OpenIDConnect/IdTokenResponse.php b/OpenIDConnect/IdTokenResponse.php new file mode 100644 index 00000000..3f73e1ac --- /dev/null +++ b/OpenIDConnect/IdTokenResponse.php @@ -0,0 +1,29 @@ +nonce = $nonce; + } + + protected function getBuilder(AccessTokenEntityInterface $accessToken, UserEntityInterface $userEntity) + { + $builder = parent::getBuilder($accessToken, $userEntity); + + if (null !== $this->nonce) { + $builder->set('nonce', $this->nonce); + } + + return $builder; + } +} diff --git a/Resources/config/doctrine/model/AuthorizationCode.orm.xml b/Resources/config/doctrine/model/AuthorizationCode.orm.xml index 1beaa55b..844d5212 100644 --- a/Resources/config/doctrine/model/AuthorizationCode.orm.xml +++ b/Resources/config/doctrine/model/AuthorizationCode.orm.xml @@ -12,6 +12,7 @@ + diff --git a/Resources/config/services.xml b/Resources/config/services.xml index fea4a55e..57c3dc50 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -71,7 +71,7 @@ - + @@ -136,7 +136,7 @@ - + From 4d84913796972056d9e862fbd4668ea6f627bb1b Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Fri, 21 Jun 2019 17:31:13 +0200 Subject: [PATCH 37/44] brings nonce back after merge --- League/Repository/AuthCodeRepository.php | 20 +++++++++++++++++++ Model/AuthorizationCode.php | 19 ++++++++++++++++++ .../doctrine/model/AuthorizationCode.orm.xml | 1 + 3 files changed, 40 insertions(+) diff --git a/League/Repository/AuthCodeRepository.php b/League/Repository/AuthCodeRepository.php index 54d8b1be..55727a28 100644 --- a/League/Repository/AuthCodeRepository.php +++ b/League/Repository/AuthCodeRepository.php @@ -64,6 +64,26 @@ public function persistNewAuthCode(AuthCodeEntityInterface $authCode) $this->authorizationCodeManager->save($authorizationCode); } + public function updateWithNonce(AuthCodeEntityInterface $authCode, string $nonce) + { + /** @var AuthorizationCode $authorizationCode */ + $authorizationCode = $this->authorizationCodeManager->find($authCode->getIdentifier()); + + if (null === $authorizationCode) { + throw new \LogicException('You cant update code that wasnt\'t persisted'); + } + + $authorizationCode->setNonce($nonce); + + $this->authorizationCodeManager->save($authorizationCode); + } + + public function getNonce(string $authCodeIdentifier) + { + $authCode = $this->authorizationCodeManager->find($authCodeIdentifier); + return $authCode->getNonce(); + } + /** * {@inheritdoc} */ diff --git a/Model/AuthorizationCode.php b/Model/AuthorizationCode.php index 06675997..2e496e01 100644 --- a/Model/AuthorizationCode.php +++ b/Model/AuthorizationCode.php @@ -38,6 +38,11 @@ class AuthorizationCode */ private $revoked = false; + /** + * @var string|null + */ + private $nonce; + public function __construct( string $identifier, DateTimeInterface $expiry, @@ -96,4 +101,18 @@ public function revoke(): self return $this; } + + public function getNonce(): ?string + { + return $this->nonce; + } + + public function setNonce(string $nonce): self + { + if ($this->nonce === null) { + $this->nonce = $nonce; + } + + return $this; + } } diff --git a/Resources/config/doctrine/model/AuthorizationCode.orm.xml b/Resources/config/doctrine/model/AuthorizationCode.orm.xml index 1beaa55b..844d5212 100644 --- a/Resources/config/doctrine/model/AuthorizationCode.orm.xml +++ b/Resources/config/doctrine/model/AuthorizationCode.orm.xml @@ -12,6 +12,7 @@ + From dffde0cc47b6d96f9ea488aa82fedff3ab93d7d5 Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Fri, 21 Jun 2019 17:31:37 +0200 Subject: [PATCH 38/44] approveAuthorization method name changed in upstream --- Model/AuthorizationDecision/AlwaysAllowDecisionStrategy.php | 2 +- Model/AuthorizationDecision/UserConsentDecisionStrategy.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Model/AuthorizationDecision/AlwaysAllowDecisionStrategy.php b/Model/AuthorizationDecision/AlwaysAllowDecisionStrategy.php index 3bca50da..022f4c32 100644 --- a/Model/AuthorizationDecision/AlwaysAllowDecisionStrategy.php +++ b/Model/AuthorizationDecision/AlwaysAllowDecisionStrategy.php @@ -8,6 +8,6 @@ class AlwaysAllowDecisionStrategy implements AuthorizationDecisionStrategy { public function decide(AuthorizationRequestResolveEvent $event) { - $event->approveAuthorization(); + $event->resolveAuthorization(true); } } diff --git a/Model/AuthorizationDecision/UserConsentDecisionStrategy.php b/Model/AuthorizationDecision/UserConsentDecisionStrategy.php index 2b6acb10..3d8e79cb 100644 --- a/Model/AuthorizationDecision/UserConsentDecisionStrategy.php +++ b/Model/AuthorizationDecision/UserConsentDecisionStrategy.php @@ -79,7 +79,7 @@ public function decide(AuthorizationRequestResolveEvent $event): void // if the request carries approval result if ($this->canResolveAuthorizationRequest($event, $request)) { if ($this->isAuthorizationAllowed($request)) { - $event->approveAuthorization(); + $event->resolveAuthorization(true); } // disapproved consent is handled by League component From 1a44f602358456e7c78a9c81121a34bce7f833bc Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Sat, 7 Sep 2019 22:26:41 +0200 Subject: [PATCH 39/44] fixes after merge --- DependencyInjection/TrikoderOAuth2Extension.php | 13 +++++-------- Resources/config/services.xml | 5 ++--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 5510a6f1..5f9e4512 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -31,6 +31,7 @@ use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\Grant as GrantType; use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\RedirectUri as RedirectUriType; use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\Scope as ScopeType; +use Trikoder\Bundle\OAuth2Bundle\Event\Listener\AuthorizationRequestAuthenticationListener; use Trikoder\Bundle\OAuth2Bundle\EventListener\ConvertExceptionToResponseListener; use Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine\AccessTokenManager; use Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine\AuthorizationCodeManager; @@ -38,6 +39,7 @@ use Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine\RefreshTokenManager; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\Scope as ScopeModel; +use Trikoder\Bundle\OAuth2Bundle\OpenIDConnect\IdTokenResponse; final class TrikoderOAuth2Extension extends Extension implements PrependExtensionInterface, CompilerPassInterface { @@ -261,11 +263,6 @@ private function configureDoctrinePersistence(ContainerBuilder $container, array ->replaceArgument('$entityManager', $entityManager) ; - $container - ->getDefinition('trikoder.oauth2.manager.doctrine.authorization_code_manager') - ->replaceArgument('$entityManager', $entityManager) - ; - $container ->getDefinition(AuthorizationCodeManager::class) ->replaceArgument('$entityManager', $entityManager) @@ -319,11 +316,11 @@ private function configureOpenIDConnect(ContainerBuilder $container, array $open { if (isset($openid_connect['enabled']) && $openid_connect['enabled']) { $container - ->getDefinition('league.oauth2.server.authorization_server') - ->setArgument(5, new Reference('openid_connect_server.id_token_response')) + ->getDefinition(AuthorizationServer::class) + ->setArgument(5, new Reference(IdTokenResponse::class)) ; $container - ->getDefinition('trikoder.oauth2.event_listener.authorization.authentication') + ->getDefinition(AuthorizationRequestAuthenticationListener::class) ->setArgument(5, $openid_connect['login_route']) ; } diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 8d9c3874..a6382218 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -98,7 +98,7 @@ - + @@ -134,7 +134,7 @@ - + @@ -143,7 +143,6 @@ - From 8d8fe9f71c9023ff2c8115fb1d5e95c0787d6b55 Mon Sep 17 00:00:00 2001 From: Michael Kubovic Date: Wed, 9 Oct 2019 16:51:37 +0200 Subject: [PATCH 40/44] Reorganized listeners to follow the upstream structure --- DependencyInjection/Configuration.php | 5 -- .../TrikoderOAuth2Extension.php | 12 ++--- Event/Listener/AuthorizationEventListener.php | 21 -------- ...horizationRequestUserResolvingListener.php | 51 ------------------- ...RequestAuthenticationResolvingListener.php | 10 ++-- ...zationRequestDecisionResolvingListener.php | 10 ++-- Resources/config/services.xml | 11 ++-- Tests/Integration/AbstractIntegrationTest.php | 9 +--- 8 files changed, 20 insertions(+), 109 deletions(-) delete mode 100644 Event/Listener/AuthorizationEventListener.php delete mode 100644 Event/Listener/AuthorizationRequestUserResolvingListener.php rename Event/Listener/AuthorizationRequestAuthenticationListener.php => EventListener/AuthorizationRequestAuthenticationResolvingListener.php (90%) rename Event/Listener/AuthorizationRequestDecisionListener.php => EventListener/AuthorizationRequestDecisionResolvingListener.php (82%) diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index c83008a2..c686291c 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -101,11 +101,6 @@ private function createAuthorizationServerNode(): NodeDefinition ->info('Whether to enable the implicit grant') ->defaultTrue() ->end() - ->scalarNode('auth_code_ttl') - ->info("How long the issued auth code should be valid for.\nThe value should be a valid interval: http://php.net/manual/en/dateinterval.construct.php#refsect1-dateinterval.construct-parameters") - ->cannotBeEmpty() - ->defaultValue('PT10M') - ->end() ->scalarNode('authorization_strategy') ->isRequired() ->info("What strategy should be used to authorize user.\nService must implement AuthorizationDecisionStrategy interface") diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 5f9e4512..21d85b8d 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -31,7 +31,8 @@ use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\Grant as GrantType; use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\RedirectUri as RedirectUriType; use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\Scope as ScopeType; -use Trikoder\Bundle\OAuth2Bundle\Event\Listener\AuthorizationRequestAuthenticationListener; +use Trikoder\Bundle\OAuth2Bundle\Event\Listener\AuthorizationRequestAuthenticationResolvingListener; +use Trikoder\Bundle\OAuth2Bundle\EventListener\AuthorizationRequestDecisionResolvingListener; use Trikoder\Bundle\OAuth2Bundle\EventListener\ConvertExceptionToResponseListener; use Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine\AccessTokenManager; use Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine\AuthorizationCodeManager; @@ -166,11 +167,6 @@ private function configureAuthorizationServer(ContainerBuilder $container, array ]); } - $authorizationServer->addMethodCall('enableGrantType', [ - new Reference('league.oauth2.server.grant.auth_code_grant'), - new Definition(DateInterval::class, [$config['access_token_ttl']]), - ]); - if ($config['enable_auth_code_grant']) { $authorizationServer->addMethodCall('enableGrantType', [ new Reference(AuthCodeGrant::class), @@ -308,7 +304,7 @@ private function configureAuthorizationStrategy(ContainerBuilder $container, str { $container->getDefinition('trikoder.oauth2.authorization_decision_strategy.user_consent')->replaceArgument(3, $consentRoute); $container - ->getDefinition('trikoder.oauth2.event_listener.authorization.decision') + ->getDefinition(AuthorizationRequestDecisionResolvingListener::class) ->replaceArgument(0, new Reference($authorizationStrategy)); } @@ -320,7 +316,7 @@ private function configureOpenIDConnect(ContainerBuilder $container, array $open ->setArgument(5, new Reference(IdTokenResponse::class)) ; $container - ->getDefinition(AuthorizationRequestAuthenticationListener::class) + ->getDefinition(AuthorizationRequestAuthenticationResolvingListener::class) ->setArgument(5, $openid_connect['login_route']) ; } diff --git a/Event/Listener/AuthorizationEventListener.php b/Event/Listener/AuthorizationEventListener.php deleted file mode 100644 index 5f21eb99..00000000 --- a/Event/Listener/AuthorizationEventListener.php +++ /dev/null @@ -1,21 +0,0 @@ -security = $security; - } - - public function onAuthorizationRequest(AuthorizationRequestResolveEvent $authRequest) - { - $authRequest->setUser($this->getUserEntity()); - } - - private function getUserEntity(): User - { - $userEntity = new User(); - - $user = $this->security->getUser(); - if ($user) { - $username = $user instanceof UserInterface ? $user->getUsername() : (string) $user; - $userEntity->setIdentifier($username); - } - - return $userEntity; - } -} diff --git a/Event/Listener/AuthorizationRequestAuthenticationListener.php b/EventListener/AuthorizationRequestAuthenticationResolvingListener.php similarity index 90% rename from Event/Listener/AuthorizationRequestAuthenticationListener.php rename to EventListener/AuthorizationRequestAuthenticationResolvingListener.php index 4c23cad1..c74b8ae8 100644 --- a/Event/Listener/AuthorizationRequestAuthenticationListener.php +++ b/EventListener/AuthorizationRequestAuthenticationResolvingListener.php @@ -1,6 +1,8 @@ - - - - - + @@ -144,6 +140,11 @@ + + + + + diff --git a/Tests/Integration/AbstractIntegrationTest.php b/Tests/Integration/AbstractIntegrationTest.php index 4c7d96d4..5114c1fd 100644 --- a/Tests/Integration/AbstractIntegrationTest.php +++ b/Tests/Integration/AbstractIntegrationTest.php @@ -161,21 +161,16 @@ protected function getAccessToken(string $jwtToken): ?AccessToken protected function getRefreshToken(string $encryptedPayload): ?RefreshToken { try { - } - - $payload = json_decode($payload, true); $payload = Crypto::decryptWithPassword($encryptedPayload, TestHelper::ENCRYPTION_KEY); } catch (CryptoException $e) { return null; - + } + $payload = json_decode($payload, true); return $this->refreshTokenManager->find( $payload['refresh_token_id'] ); } - - return $this->refreshTokenManager->find( - $payload['refresh_token_id'] protected function getIdToken(string $jwtToken): Token { return (new Parser())->parse($jwtToken); From 8c0573bf70ab2fac59029037ddebe0322059f170 Mon Sep 17 00:00:00 2001 From: Tayfun Aydin Date: Fri, 27 Dec 2019 15:19:39 +0100 Subject: [PATCH 41/44] fixed the use of listener --- DependencyInjection/TrikoderOAuth2Extension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 21d85b8d..64a29c7c 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -31,7 +31,7 @@ use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\Grant as GrantType; use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\RedirectUri as RedirectUriType; use Trikoder\Bundle\OAuth2Bundle\DBAL\Type\Scope as ScopeType; -use Trikoder\Bundle\OAuth2Bundle\Event\Listener\AuthorizationRequestAuthenticationResolvingListener; +use Trikoder\Bundle\OAuth2Bundle\EventListener\AuthorizationRequestAuthenticationResolvingListener; use Trikoder\Bundle\OAuth2Bundle\EventListener\AuthorizationRequestDecisionResolvingListener; use Trikoder\Bundle\OAuth2Bundle\EventListener\ConvertExceptionToResponseListener; use Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine\AccessTokenManager; From 667ed6e2606a3bde04b8fca428a4ffa1081c50a5 Mon Sep 17 00:00:00 2001 From: Tayfun Aydin Date: Thu, 2 Jan 2020 12:16:59 +0100 Subject: [PATCH 42/44] redirect fix --- Model/AuthorizationDecision/UserConsentDecisionStrategy.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Model/AuthorizationDecision/UserConsentDecisionStrategy.php b/Model/AuthorizationDecision/UserConsentDecisionStrategy.php index 3d8e79cb..aba81f2e 100644 --- a/Model/AuthorizationDecision/UserConsentDecisionStrategy.php +++ b/Model/AuthorizationDecision/UserConsentDecisionStrategy.php @@ -134,7 +134,7 @@ private function createRedirectToConsentResponse(AuthorizationRequestResolveEven } $redirectUri = $this->urlGenerator->generate($this->consentApprovalRoute, $params); - return new Response(null, 302, ['Location' => $redirectUri]); + return new Response\RedirectResponse($redirectUri); } private function getResponseType(AuthorizationRequestResolveEvent $event): string From 766c513c30389df4d67ea6fea975a5981815ff78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ben=C4=8Do?= Date: Fri, 20 Mar 2020 09:18:54 +0100 Subject: [PATCH 43/44] Upgrade lib to V3 --- .gitattributes | 9 + .php_cs.dist | 1 + .travis.yml | 38 ++- CHANGELOG.md | 38 +++ Command/ClearExpiredTokensCommand.php | 87 ++++-- Command/CreateClientCommand.php | 39 ++- Command/ListClientsCommand.php | 13 +- Command/UpdateClientCommand.php | 6 +- Controller/AuthorizationController.php | 22 +- Controller/UserInfoController.php | 6 +- DBAL/Type/ImplodedArray.php | 4 +- .../EventDispatcherCompilerPass.php | 38 --- DependencyInjection/Configuration.php | 48 ++-- .../Security/OAuth2Factory.php | 6 +- .../TrikoderOAuth2Extension.php | 31 ++- Event/AuthorizationRequestResolveEvent.php | 35 +-- ...uthorizationRequestResolveEventFactory.php | 11 +- Event/ClaimsResolveEvent.php | 2 +- Event/ScopeResolveEvent.php | 2 +- Event/UserResolveEvent.php | 2 +- .../ConvertExceptionToResponseListener.php | 6 +- League/Entity/Client.php | 20 ++ League/Repository/AccessTokenRepository.php | 14 +- League/Repository/AuthCodeRepository.php | 6 +- League/Repository/ClientRepository.php | 38 ++- League/Repository/RefreshTokenRepository.php | 4 +- League/Repository/ScopeRepository.php | 9 +- League/Repository/UserRepository.php | 9 +- Manager/AuthorizationCodeManagerInterface.php | 2 + Manager/Doctrine/AccessTokenManager.php | 4 +- Manager/Doctrine/AuthorizationCodeManager.php | 11 + Manager/Doctrine/RefreshTokenManager.php | 4 +- Manager/InMemory/AccessTokenManager.php | 6 +- Manager/InMemory/AuthorizationCodeManager.php | 13 + Manager/InMemory/ClientManager.php | 2 +- Manager/InMemory/RefreshTokenManager.php | 6 +- Model/Client.php | 28 +- Model/Grant.php | 4 +- Model/RedirectUri.php | 4 +- OpenIDConnect/Repository/IdentityProvider.php | 4 +- README.md | 11 +- .../config/doctrine/model/AccessToken.orm.xml | 6 +- .../doctrine/model/AuthorizationCode.orm.xml | 4 +- .../config/doctrine/model/Client.orm.xml | 9 +- .../doctrine/model/RefreshToken.orm.xml | 4 +- Resources/config/routes.xml | 2 + Resources/config/services.xml | 16 +- Resources/config/storage/doctrine.xml | 2 + Resources/config/storage/in_memory.xml | 2 + .../Provider/OAuth2Provider.php | 32 ++- Security/Authentication/Token/OAuth2Token.php | 35 ++- .../Token/OAuth2TokenFactory.php | 26 ++ Security/EntryPoint/OAuth2EntryPoint.php | 2 +- Security/Firewall/OAuth2Listener.php | 34 ++- Service/BCEventDispatcher.php | 39 --- Tests/Acceptance/AbstractAcceptanceTest.php | 5 +- .../Acceptance/AuthorizationEndpointTest.php | 258 ++++++++++++++++-- .../ClearExpiredTokensCommandTest.php | 114 +++++++- Tests/Acceptance/CreateClientCommandTest.php | 83 ++++++ Tests/Acceptance/DeleteClientCommandTest.php | 19 +- .../DoctrineAccessTokenManagerTest.php | 22 +- .../DoctrineAuthCodeManagerTest.php | 83 ++++++ .../Acceptance/DoctrineClientManagerTest.php | 116 ++++++++ .../DoctrineRefreshTokenManagerTest.php | 16 +- Tests/Acceptance/TokenEndpointTest.php | 72 +++-- Tests/Acceptance/UserInfoEndpointTest.php | 17 ++ Tests/Fixtures/FixtureFactory.php | 56 ++-- Tests/Fixtures/SecurityTestController.php | 18 +- Tests/Integration/AbstractIntegrationTest.php | 30 +- Tests/Integration/AuthCodeRepositoryTest.php | 38 +++ Tests/Integration/AuthorizationServerTest.php | 33 +-- Tests/Integration/OpenIDProviderTest.php | 15 +- Tests/Integration/ResourceServerTest.php | 3 +- Tests/Support/SqlitePlatform.php | 38 +++ Tests/TestHelper.php | 22 +- Tests/TestKernel.php | 55 ++-- Tests/Unit/ClientEntityTest.php | 30 ++ Tests/Unit/ExtensionTest.php | 58 +++- Tests/Unit/InMemoryAccessTokenManagerTest.php | 6 +- Tests/Unit/InMemoryAuthCodeManagerTest.php | 72 +++++ .../Unit/InMemoryRefreshTokenManagerTest.php | 8 +- Tests/Unit/OAuth2ProviderTest.php | 49 ++++ Tests/Unit/OAuth2TokenFactoryTest.php | 43 +++ Tests/Unit/OAuth2TokenTest.php | 40 +++ TrikoderOAuth2Bundle.php | 2 - UPGRADE.md | 44 +++ composer.json | 38 +-- docs/basic-setup.md | 21 +- 88 files changed, 1897 insertions(+), 483 deletions(-) create mode 100644 .gitattributes delete mode 100644 DependencyInjection/CompilerPass/EventDispatcherCompilerPass.php create mode 100644 Security/Authentication/Token/OAuth2TokenFactory.php delete mode 100644 Service/BCEventDispatcher.php create mode 100644 Tests/Acceptance/DoctrineAuthCodeManagerTest.php create mode 100644 Tests/Acceptance/DoctrineClientManagerTest.php create mode 100644 Tests/Integration/AuthCodeRepositoryTest.php create mode 100644 Tests/Support/SqlitePlatform.php create mode 100644 Tests/Unit/ClientEntityTest.php create mode 100644 Tests/Unit/InMemoryAuthCodeManagerTest.php create mode 100644 Tests/Unit/OAuth2ProviderTest.php create mode 100644 Tests/Unit/OAuth2TokenFactoryTest.php create mode 100644 Tests/Unit/OAuth2TokenTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..7809bfb4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +/Tests export-ignore +/dev export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php_cs.dist export-ignore +/.travis.yml export-ignore +/docker-compose.yml export-ignore +/phpunit.xml.dist export-ignore diff --git a/.php_cs.dist b/.php_cs.dist index 2d146f50..d3c2e277 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -34,6 +34,7 @@ return PhpCsFixer\Config::create() 'yoda_style' => true, 'compact_nullable_typehint' => true, 'visibility_required' => true, + 'nullable_type_declaration_for_default_null_value' => true, ]) ->setRiskyAllowed(true) ->setFinder($finder) diff --git a/.travis.yml b/.travis.yml index fe633763..d8183811 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,5 @@ +dist: bionic + sudo: required language: bash @@ -5,38 +7,34 @@ language: bash services: - docker -addons: - apt: - packages: - - docker-ce - env: # PHP 7.2 - - PHP_VERSION=7.2 PSR_HTTP_PROVIDER=nyholm SYMFONY_VERSION=3.4.* - - PHP_VERSION=7.2 PSR_HTTP_PROVIDER=nyholm SYMFONY_VERSION=4.2.* - - PHP_VERSION=7.2 PSR_HTTP_PROVIDER=nyholm SYMFONY_VERSION=4.3.* - - PHP_VERSION=7.2 PSR_HTTP_PROVIDER=zendframework SYMFONY_VERSION=3.4.* - - PHP_VERSION=7.2 PSR_HTTP_PROVIDER=zendframework SYMFONY_VERSION=4.2.* - - PHP_VERSION=7.2 PSR_HTTP_PROVIDER=zendframework SYMFONY_VERSION=4.3.* + - PHP_VERSION=7.2 PSR_HTTP_PROVIDER=nyholm SYMFONY_REQUIRE=4.4.* + - PHP_VERSION=7.2 PSR_HTTP_PROVIDER=zendframework SYMFONY_REQUIRE=4.4.* + - PHP_VERSION=7.2 PSR_HTTP_PROVIDER=nyholm SYMFONY_REQUIRE=5.0.* + - PHP_VERSION=7.2 PSR_HTTP_PROVIDER=zendframework SYMFONY_REQUIRE=5.0.* # PHP 7.3 - - PHP_VERSION=7.3 PSR_HTTP_PROVIDER=nyholm SYMFONY_VERSION=3.4.* - - PHP_VERSION=7.3 PSR_HTTP_PROVIDER=nyholm SYMFONY_VERSION=4.2.* - - PHP_VERSION=7.3 PSR_HTTP_PROVIDER=nyholm SYMFONY_VERSION=4.3.* - - PHP_VERSION=7.3 PSR_HTTP_PROVIDER=zendframework SYMFONY_VERSION=3.4.* - - PHP_VERSION=7.3 PSR_HTTP_PROVIDER=zendframework SYMFONY_VERSION=4.2.* - - PHP_VERSION=7.3 PSR_HTTP_PROVIDER=zendframework SYMFONY_VERSION=4.3.* + - PHP_VERSION=7.3 PSR_HTTP_PROVIDER=nyholm SYMFONY_REQUIRE=4.4.* + - PHP_VERSION=7.3 PSR_HTTP_PROVIDER=zendframework SYMFONY_REQUIRE=4.4.* + - PHP_VERSION=7.3 PSR_HTTP_PROVIDER=nyholm SYMFONY_REQUIRE=5.0.* + - PHP_VERSION=7.3 PSR_HTTP_PROVIDER=zendframework SYMFONY_REQUIRE=5.0.* + + # PHP 7.4 + - PHP_VERSION=7.4 PSR_HTTP_PROVIDER=nyholm SYMFONY_REQUIRE=4.4.* + - PHP_VERSION=7.4 PSR_HTTP_PROVIDER=zendframework SYMFONY_REQUIRE=4.4.* + - PHP_VERSION=7.4 PSR_HTTP_PROVIDER=nyholm SYMFONY_REQUIRE=5.0.* + - PHP_VERSION=7.4 PSR_HTTP_PROVIDER=zendframework SYMFONY_REQUIRE=5.0.* install: - dev/bin/docker-compose build --build-arg PHP_VERSION=${PHP_VERSION} php before_script: - # Our docker image has symfony/flex installed to make sure SYMFONY_VERSION is working - - dev/bin/php composer config extra.symfony.require "${SYMFONY_VERSION}" + # Our docker image has symfony/flex installed to make sure SYMFONY_REQUIRE is working - dev/bin/php composer update --ansi --prefer-dist script: - - dev/bin/php composer test -- --colors=always --coverage-clover=coverage.xml --debug + - dev/bin/php-test composer test -- --colors=always --coverage-clover=coverage.xml --debug - dev/bin/php composer lint -- --ansi --diff --dry-run --using-cache=no --verbose after_script: diff --git a/CHANGELOG.md b/CHANGELOG.md index 15a9a3f2..0484deff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,44 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [3.0.0] - 2020-02-26 +### Added +- Ability to restrict clients from using the `plain` challenge method during PKCE ([4562a1f](https://github.com/trikoder/oauth2-bundle/commit/4562a1ff306375fd651aa91c85d0d4fd6f4c1b13)) +- Ability to clear expired authorization codes ([91b6447](https://github.com/trikoder/oauth2-bundle/commit/91b6447257419d8e961c4f5b0abd187f1b735856)) +- Support for defining public (non-confidential) clients ([8a71f55](https://github.com/trikoder/oauth2-bundle/commit/8a71f55aa1482d00cee66684141cc9ef81d31f31)) +- The bundle is now compatible with Symfony 5.x ([3f36977](https://github.com/trikoder/oauth2-bundle/commit/3f369771385c0b90855da712b9cb31faa4c651dc)) + +### Changed +- [PSR-7 Bridge](https://github.com/symfony/psr-http-message-bridge) version constraint to `^2.0` ([3c741ca](https://github.com/trikoder/oauth2-bundle/commit/3c741ca1e394886e8936ad018c28cd1ddd3dff02)) +- The bundle now relies on `8.x` versions of [league/oauth2-server](https://github.com/thephpleague/oauth2-server) for base functionality ([8becc18](https://github.com/trikoder/oauth2-bundle/commit/8becc18255052a73d0f76a030be9de0fe9868928)) + +### Removed +- Support for Symfony 3.4, 4.2 and 4.3 ([3f36977](https://github.com/trikoder/oauth2-bundle/commit/3f369771385c0b90855da712b9cb31faa4c651dc)) + +## [2.1.1] - 2020-02-25 +### Added +- The bundle is now additionally tested against PHP 7.4 ([2b29be3](https://github.com/trikoder/oauth2-bundle/commit/2b29be3629877a648f4a199b96185b40d625f6aa)) + +### Fixed +- Authentication provider not being aware of the current firewall context ([d349329](https://github.com/trikoder/oauth2-bundle/commit/d349329056c219969e097ae6bd3eb724968f9812)) +- Faulty logic when revoking authorization codes ([24ad882](https://github.com/trikoder/oauth2-bundle/commit/24ad88211cefddf97170f5c1cc8ba1e5cf285e42)) + +## [2.1.0] - 2019-12-09 +### Added +- Ability to change the scope role prefix using the `role_prefix` configuration option ([b2ee617](https://github.com/trikoder/oauth2-bundle/commit/b2ee6179832cc142d95e3b13d9af09d6cb6831d5)) +- Interfaces for converter type service classes ([d2caf69](https://github.com/trikoder/oauth2-bundle/commit/d2caf690839523a2c84d967a6f99787898d4c654)) +- New testing target in Travis CI for Symfony 4.4 ([8a44fd4](https://github.com/trikoder/oauth2-bundle/commit/8a44fd4d7673467cc4f69988424cdfc677767aab)) +- The bundle is now fully compatible with [Symfony Flex](https://github.com/symfony/flex) ([a4ccea1](https://github.com/trikoder/oauth2-bundle/commit/a4ccea1dfaaba6d95daf3e1f1a84952cafb65d01)) + +### Changed +- [DoctrineBundle](https://github.com/doctrine/DoctrineBundle) version constraint to allow `2.x` derived versions ([885e398](https://github.com/trikoder/oauth2-bundle/commit/885e39811331e89bae99bca71f1a783497d26d12)) +- Explicitly list [league/oauth2-server](https://github.com/thephpleague/oauth2-server) version requirements in the documentation ([9dce66a](https://github.com/trikoder/oauth2-bundle/commit/9dce66a089c33c224fe5cb58bdfd6285350a607b)) +- Reduce distributed package size by excluding files that are used only for development ([80b9e41](https://github.com/trikoder/oauth2-bundle/commit/80b9e41155e7a94c3b1a4602c8daa25cc6d246b2)) +- Simplify `AuthorizationRequestResolveEvent` class creation ([32908c1](https://github.com/trikoder/oauth2-bundle/commit/32908c1a4a89fd89d5835d4de931d237de223b50)) + +### Fixed +- Not being able to delete clients that have access/refresh tokens assigned to them ([424b770](https://github.com/trikoder/oauth2-bundle/commit/424b770dbd99e4651777a3fa26186a756b4e93c4)) + ## [2.0.1] - 2019-08-13 ### Removed - PSR-7/17 alias check during the container compile process ([0847ea3](https://github.com/trikoder/oauth2-bundle/commit/0847ea3034cc433c9c8f92ec46fedbdace259e3d)) diff --git a/Command/ClearExpiredTokensCommand.php b/Command/ClearExpiredTokensCommand.php index 6116699e..c01c4269 100644 --- a/Command/ClearExpiredTokensCommand.php +++ b/Command/ClearExpiredTokensCommand.php @@ -10,6 +10,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Manager\AuthorizationCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; final class ClearExpiredTokensCommand extends Command @@ -26,31 +27,44 @@ final class ClearExpiredTokensCommand extends Command */ private $refreshTokenManager; + /** + * @var AuthorizationCodeManagerInterface + */ + private $authorizationCodeManager; + public function __construct( AccessTokenManagerInterface $accessTokenManager, - RefreshTokenManagerInterface $refreshTokenManager + RefreshTokenManagerInterface $refreshTokenManager, + AuthorizationCodeManagerInterface $authorizationCodeManager ) { parent::__construct(); $this->accessTokenManager = $accessTokenManager; $this->refreshTokenManager = $refreshTokenManager; + $this->authorizationCodeManager = $authorizationCodeManager; } protected function configure(): void { $this - ->setDescription('Clears all expired access and/or refresh tokens') + ->setDescription('Clears all expired access and/or refresh tokens and/or auth codes') ->addOption( - 'access-tokens-only', + 'access-tokens', 'a', InputOption::VALUE_NONE, - 'Clear only access tokens.' + 'Clear expired access tokens.' ) ->addOption( - 'refresh-tokens-only', + 'refresh-tokens', 'r', InputOption::VALUE_NONE, - 'Clear only refresh tokens.' + 'Clear expired refresh tokens.' + ) + ->addOption( + 'auth-codes', + 'c', + InputOption::VALUE_NONE, + 'Clear expired auth codes.' ) ; } @@ -59,33 +73,60 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $clearExpiredAccessTokens = !$input->getOption('refresh-tokens-only'); - $clearExpiredRefreshTokens = !$input->getOption('access-tokens-only'); + $clearExpiredAccessTokens = $input->getOption('access-tokens'); + $clearExpiredRefreshTokens = $input->getOption('refresh-tokens'); + $clearExpiredAuthCodes = $input->getOption('auth-codes'); - if (!$clearExpiredAccessTokens && !$clearExpiredRefreshTokens) { - $io->error('Please choose only one of the following options: "access-tokens-only", "refresh-tokens-only".'); + if (!$clearExpiredAccessTokens && !$clearExpiredRefreshTokens && !$clearExpiredAuthCodes) { + $this->clearExpiredAccessTokens($io); + $this->clearExpiredRefreshTokens($io); + $this->clearExpiredAuthCodes($io); - return 1; + return 0; } if (true === $clearExpiredAccessTokens) { - $numOfClearedAccessTokens = $this->accessTokenManager->clearExpired(); - $io->success(sprintf( - 'Cleared %d expired access token%s.', - $numOfClearedAccessTokens, - 1 === $numOfClearedAccessTokens ? '' : 's' - )); + $this->clearExpiredAccessTokens($io); } if (true === $clearExpiredRefreshTokens) { - $numOfClearedRefreshTokens = $this->refreshTokenManager->clearExpired(); - $io->success(sprintf( - 'Cleared %d expired refresh token%s.', - $numOfClearedRefreshTokens, - 1 === $numOfClearedRefreshTokens ? '' : 's' - )); + $this->clearExpiredRefreshTokens($io); + } + + if (true === $clearExpiredAuthCodes) { + $this->clearExpiredAuthCodes($io); } return 0; } + + private function clearExpiredAccessTokens(SymfonyStyle $io): void + { + $numOfClearedAccessTokens = $this->accessTokenManager->clearExpired(); + $io->success(sprintf( + 'Cleared %d expired access token%s.', + $numOfClearedAccessTokens, + 1 === $numOfClearedAccessTokens ? '' : 's' + )); + } + + private function clearExpiredRefreshTokens(SymfonyStyle $io): void + { + $numOfClearedRefreshTokens = $this->refreshTokenManager->clearExpired(); + $io->success(sprintf( + 'Cleared %d expired refresh token%s.', + $numOfClearedRefreshTokens, + 1 === $numOfClearedRefreshTokens ? '' : 's' + )); + } + + private function clearExpiredAuthCodes(SymfonyStyle $io): void + { + $numOfClearedAuthCodes = $this->authorizationCodeManager->clearExpired(); + $io->success(sprintf( + 'Cleared %d expired auth code%s.', + $numOfClearedAuthCodes, + 1 === $numOfClearedAuthCodes ? '' : 's' + )); + } } diff --git a/Command/CreateClientCommand.php b/Command/CreateClientCommand.php index ac8dbba9..8c5bd381 100644 --- a/Command/CreateClientCommand.php +++ b/Command/CreateClientCommand.php @@ -4,6 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Command; +use InvalidArgumentException; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -67,13 +68,33 @@ protected function configure(): void InputArgument::OPTIONAL, 'The client secret' ) + ->addOption( + 'public', + null, + InputOption::VALUE_NONE, + 'Create a public client.' + ) + ->addOption( + 'allow-plain-text-pkce', + null, + InputOption::VALUE_NONE, + 'Create a client who is allowed to use plain challenge method for PKCE.' + ) ; } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $client = $this->buildClientFromInput($input); + + try { + $client = $this->buildClientFromInput($input); + } catch (InvalidArgumentException $exception) { + $io->error($exception->getMessage()); + + return 1; + } + $this->clientManager->save($client); $io->success('New oAuth2 client created successfully.'); @@ -89,25 +110,33 @@ protected function execute(InputInterface $input, OutputInterface $output): int private function buildClientFromInput(InputInterface $input): Client { $identifier = $input->getArgument('identifier') ?? hash('md5', random_bytes(16)); - $secret = $input->getArgument('secret') ?? hash('sha512', random_bytes(32)); + + $isPublic = $input->getOption('public'); + + if (null !== $input->getArgument('secret') && $isPublic) { + throw new InvalidArgumentException('The client cannot have a secret and be public.'); + } + + $secret = $isPublic ? null : $input->getArgument('secret') ?? hash('sha512', random_bytes(32)); $client = new Client($identifier, $secret); $client->setActive(true); + $client->setAllowPlainTextPkce($input->getOption('allow-plain-text-pkce')); $redirectUris = array_map( - function (string $redirectUri): RedirectUri { return new RedirectUri($redirectUri); }, + static function (string $redirectUri): RedirectUri { return new RedirectUri($redirectUri); }, $input->getOption('redirect-uri') ); $client->setRedirectUris(...$redirectUris); $grants = array_map( - function (string $grant): Grant { return new Grant($grant); }, + static function (string $grant): Grant { return new Grant($grant); }, $input->getOption('grant-type') ); $client->setGrants(...$grants); $scopes = array_map( - function (string $scope): Scope { return new Scope($scope); }, + static function (string $scope): Scope { return new Scope($scope); }, $input->getOption('scope') ); $client->setScopes(...$scopes); diff --git a/Command/ListClientsCommand.php b/Command/ListClientsCommand.php index abfe1cbe..2cbcdc66 100644 --- a/Command/ListClientsCommand.php +++ b/Command/ListClientsCommand.php @@ -30,6 +30,7 @@ final class ListClientsCommand extends Command public function __construct(ClientManagerInterface $clientManager) { parent::__construct(); + $this->clientManager = $clientManager; } @@ -82,13 +83,13 @@ private function getFindByCriteria(InputInterface $input): ClientFilter return ClientFilter ::create() - ->addGrantCriteria(...array_map(function (string $grant): Grant { + ->addGrantCriteria(...array_map(static function (string $grant): Grant { return new Grant($grant); }, $input->getOption('grant-type'))) - ->addRedirectUriCriteria(...array_map(function (string $redirectUri): RedirectUri { + ->addRedirectUriCriteria(...array_map(static function (string $redirectUri): RedirectUri { return new RedirectUri($redirectUri); }, $input->getOption('redirect-uri'))) - ->addScopeCriteria(...array_map(function (string $scope): Scope { + ->addScopeCriteria(...array_map(static function (string $scope): Scope { return new Scope($scope); }, $input->getOption('scope'))) ; @@ -104,7 +105,7 @@ private function drawTable(InputInterface $input, OutputInterface $output, array private function getRows(array $clients, array $columns): array { - return array_map(function (Client $client) use ($columns): array { + return array_map(static function (Client $client) use ($columns): array { $values = [ 'identifier' => $client->getIdentifier(), 'secret' => $client->getSecret(), @@ -113,7 +114,7 @@ private function getRows(array $clients, array $columns): array 'grant type' => implode(', ', $client->getGrants()), ]; - return array_map(function (string $column) use ($values): string { + return array_map(static function (string $column) use ($values): string { return $values[$column]; }, $columns); }, $clients); @@ -122,7 +123,7 @@ private function getRows(array $clients, array $columns): array private function getColumns(InputInterface $input): array { $requestedColumns = $input->getOption('columns'); - $requestedColumns = array_map(function (string $column): string { + $requestedColumns = array_map(static function (string $column): string { return strtolower(trim($column)); }, $requestedColumns); diff --git a/Command/UpdateClientCommand.php b/Command/UpdateClientCommand.php index ccc9a72b..624c7be1 100644 --- a/Command/UpdateClientCommand.php +++ b/Command/UpdateClientCommand.php @@ -93,19 +93,19 @@ private function updateClientFromInput(Client $client, InputInterface $input): C $client->setActive(!$input->getOption('deactivated')); $redirectUris = array_map( - function (string $redirectUri): RedirectUri { return new RedirectUri($redirectUri); }, + static function (string $redirectUri): RedirectUri { return new RedirectUri($redirectUri); }, $input->getOption('redirect-uri') ); $client->setRedirectUris(...$redirectUris); $grants = array_map( - function (string $grant): Grant { return new Grant($grant); }, + static function (string $grant): Grant { return new Grant($grant); }, $input->getOption('grant-type') ); $client->setGrants(...$grants); $scopes = array_map( - function (string $scope): Scope { return new Scope($scope); }, + static function (string $scope): Scope { return new Scope($scope); }, $input->getOption('scope') ); $client->setScopes(...$scopes); diff --git a/Controller/AuthorizationController.php b/Controller/AuthorizationController.php index d809586f..68253057 100644 --- a/Controller/AuthorizationController.php +++ b/Controller/AuthorizationController.php @@ -9,10 +9,11 @@ use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Trikoder\Bundle\OAuth2Bundle\Converter\UserConverterInterface; use Trikoder\Bundle\OAuth2Bundle\Event\AuthorizationRequestResolveEvent; use Trikoder\Bundle\OAuth2Bundle\Event\AuthorizationRequestResolveEventFactory; +use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; use Trikoder\Bundle\OAuth2Bundle\OAuth2Events; final class AuthorizationController @@ -37,16 +38,23 @@ final class AuthorizationController */ private $userConverter; + /** + * @var ClientManagerInterface + */ + private $clientManager; + public function __construct( AuthorizationServer $server, EventDispatcherInterface $eventDispatcher, AuthorizationRequestResolveEventFactory $eventFactory, - UserConverterInterface $userConverter + UserConverterInterface $userConverter, + ClientManagerInterface $clientManager ) { $this->server = $server; $this->eventDispatcher = $eventDispatcher; $this->eventFactory = $eventFactory; $this->userConverter = $userConverter; + $this->clientManager = $clientManager; } public function indexAction(ServerRequestInterface $serverRequest, ResponseFactoryInterface $responseFactory): ResponseInterface @@ -56,6 +64,16 @@ public function indexAction(ServerRequestInterface $serverRequest, ResponseFacto try { $authRequest = $this->server->validateAuthorizationRequest($serverRequest); + if ('plain' === $authRequest->getCodeChallengeMethod()) { + $client = $this->clientManager->find($authRequest->getClient()->getIdentifier()); + if (!$client->isPlainTextPkceAllowed()) { + return OAuthServerException::invalidRequest( + 'code_challenge_method', + 'Plain code challenge method is not allowed for this client' + )->generateHttpResponse($serverResponse); + } + } + /** @var AuthorizationRequestResolveEvent $event */ $event = $this->eventDispatcher->dispatch( $this->eventFactory->fromAuthorizationRequest($authRequest), diff --git a/Controller/UserInfoController.php b/Controller/UserInfoController.php index decf0b12..f4a79fed 100644 --- a/Controller/UserInfoController.php +++ b/Controller/UserInfoController.php @@ -6,9 +6,9 @@ use League\OAuth2\Server\ResourceServer; use OpenIDConnectServer\ClaimExtractor; use OpenIDConnectServer\Repositories\IdentityProviderInterface; +use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ServerRequestInterface; use Symfony\Component\HttpFoundation\JsonResponse; -use Zend\Diactoros\Response as Psr7Response; final class UserInfoController { @@ -23,14 +23,14 @@ public function __construct(ResourceServer $server, IdentityProviderInterface $i $this->claimExtractor = $claimExtractor; } - public function indexAction(ServerRequestInterface $serverRequest) + public function indexAction(ServerRequestInterface $serverRequest, ResponseFactoryInterface $responseFactory) { $request = $this->serverRequestWithBearerToken($serverRequest); try { $validatedRequest = $this->server->validateAuthenticatedRequest($request); } catch (OAuthServerException $e) { - return $e->generateHttpResponse(new Psr7Response()); + return $e->generateHttpResponse($responseFactory->createResponse()); } $userEntity = $this->identityProvider->getUserEntityByIdentifier($validatedRequest->getAttribute('oauth_user_id')); diff --git a/DBAL/Type/ImplodedArray.php b/DBAL/Type/ImplodedArray.php index 725a2156..78548c41 100644 --- a/DBAL/Type/ImplodedArray.php +++ b/DBAL/Type/ImplodedArray.php @@ -82,9 +82,7 @@ private function assertValueCanBeImploded($value): void return; } - throw new InvalidArgumentException( - sprintf('The value of \'%s\' type cannot be imploded.', \gettype($value)) - ); + throw new InvalidArgumentException(sprintf('The value of \'%s\' type cannot be imploded.', \gettype($value))); } abstract protected function convertDatabaseValues(array $values): array; diff --git a/DependencyInjection/CompilerPass/EventDispatcherCompilerPass.php b/DependencyInjection/CompilerPass/EventDispatcherCompilerPass.php deleted file mode 100644 index 863cf7f6..00000000 --- a/DependencyInjection/CompilerPass/EventDispatcherCompilerPass.php +++ /dev/null @@ -1,38 +0,0 @@ -has(EventDispatcherInterface::class)) { - return; - } - - // Register a new service - $definition = new Definition(BCEventDispatcher::class); - $definition->addArgument(new Reference(\Symfony\Component\EventDispatcher\EventDispatcherInterface::class)); - $container->setDefinition(BCEventDispatcher::class, $definition); - - // Use our new service - $container->getDefinition(ScopeRepository::class) - ->replaceArgument(3, new Reference(BCEventDispatcher::class)); - $container->getDefinition(UserRepository::class) - ->replaceArgument(1, new Reference(BCEventDispatcher::class)); - $container->getDefinition(AuthorizationController::class) - ->replaceArgument(1, new Reference(BCEventDispatcher::class)); - } -} diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index c686291c..9c24ff03 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -15,9 +15,9 @@ final class Configuration implements ConfigurationInterface /** * {@inheritdoc} */ - public function getConfigTreeBuilder() + public function getConfigTreeBuilder(): TreeBuilder { - $treeBuilder = $this->getWrappedTreeBuilder('trikoder_oauth2'); + $treeBuilder = new TreeBuilder('trikoder_oauth2'); $rootNode = $treeBuilder->getRootNode(); $rootNode->append($this->createAuthorizationServerNode()); @@ -32,6 +32,11 @@ public function getConfigTreeBuilder() ->info('The priority of the event listener that converts an Exception to a Response') ->defaultValue(10) ->end() + ->scalarNode('role_prefix') + ->info('Set a custom prefix that replaces the default \'ROLE_OAUTH2_\' role prefix') + ->defaultValue('ROLE_OAUTH2_') + ->cannotBeEmpty() + ->end() ->end(); return $treeBuilder; @@ -39,8 +44,7 @@ public function getConfigTreeBuilder() private function createAuthorizationServerNode(): NodeDefinition { - /** @var TreeBuilder $treeBuilder */ - $treeBuilder = $this->getWrappedTreeBuilder('authorization_server'); + $treeBuilder = new TreeBuilder('authorization_server'); $node = $treeBuilder->getRootNode(); $node @@ -97,6 +101,10 @@ private function createAuthorizationServerNode(): NodeDefinition ->info('Whether to enable the authorization code grant') ->defaultTrue() ->end() + ->booleanNode('require_code_challenge_for_public_clients') + ->info('Whether to require code challenge for public clients for the auth code grant') + ->defaultTrue() + ->end() ->booleanNode('enable_implicit_grant') ->info('Whether to enable the implicit grant') ->defaultTrue() @@ -119,7 +127,7 @@ private function createAuthorizationServerNode(): NodeDefinition private function createResourceServerNode(): NodeDefinition { - $treeBuilder = $this->getWrappedTreeBuilder('resource_server'); + $treeBuilder = new TreeBuilder('resource_server'); $node = $treeBuilder->getRootNode(); $node @@ -139,7 +147,7 @@ private function createResourceServerNode(): NodeDefinition private function createScopesNode(): NodeDefinition { - $treeBuilder = $this->getWrappedTreeBuilder('scopes'); + $treeBuilder = new TreeBuilder('scopes'); $node = $treeBuilder->getRootNode(); $node @@ -153,7 +161,7 @@ private function createScopesNode(): NodeDefinition private function createPersistenceNode(): NodeDefinition { - $treeBuilder = $this->getWrappedTreeBuilder('persistence'); + $treeBuilder = new TreeBuilder('persistence'); $node = $treeBuilder->getRootNode(); $node @@ -182,8 +190,7 @@ private function createPersistenceNode(): NodeDefinition private function createOpenIDConnectNode(): NodeDefinition { - /** @var TreeBuilder $treeBuilder */ - $treeBuilder = $this->getWrappedTreeBuilder('openid_connect'); + $treeBuilder = new TreeBuilder('openid_connect'); $node = $treeBuilder->getRootNode(); $node @@ -204,27 +211,4 @@ private function createOpenIDConnectNode(): NodeDefinition return $node; } - - private function getWrappedTreeBuilder(string $name): object - { - return new class($name) extends TreeBuilder { - public function __construct(string $name) - { - // Compatibility path for Symfony 3.4 - if (!method_exists(TreeBuilder::class, 'getRootNode')) { - $this->root($name); - } - - // Compatibility path for Symfony 4.2+ - if (method_exists(TreeBuilder::class, '__construct')) { - parent::__construct($name); - } - } - - public function getRootNode(): NodeDefinition - { - return $this->root; - } - }; - } } diff --git a/DependencyInjection/Security/OAuth2Factory.php b/DependencyInjection/Security/OAuth2Factory.php index dc4404bb..30c2fa77 100644 --- a/DependencyInjection/Security/OAuth2Factory.php +++ b/DependencyInjection/Security/OAuth2Factory.php @@ -23,11 +23,13 @@ public function create(ContainerBuilder $container, $id, $config, $userProvider, $providerId = 'security.authentication.provider.oauth2.' . $id; $container ->setDefinition($providerId, new ChildDefinition(OAuth2Provider::class)) - ->replaceArgument('$userProvider', new Reference($userProvider)); + ->replaceArgument('$userProvider', new Reference($userProvider)) + ->replaceArgument('$providerKey', $id); $listenerId = 'security.authentication.listener.oauth2.' . $id; $container - ->setDefinition($listenerId, new ChildDefinition(OAuth2Listener::class)); + ->setDefinition($listenerId, new ChildDefinition(OAuth2Listener::class)) + ->replaceArgument('$providerKey', $id); return [$providerId, $listenerId, OAuth2EntryPoint::class]; } diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 64a29c7c..25ee1221 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -7,6 +7,7 @@ use DateInterval; use Defuse\Crypto\Key; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; +use Exception; use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\CryptKey; use League\OAuth2\Server\Grant\AuthCodeGrant; @@ -16,6 +17,7 @@ use League\OAuth2\Server\Grant\RefreshTokenGrant; use League\OAuth2\Server\ResourceServer; use LogicException; +use RuntimeException; use Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Component\Config\FileLocator; @@ -40,12 +42,15 @@ use Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine\RefreshTokenManager; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\Scope as ScopeModel; +use Trikoder\Bundle\OAuth2Bundle\Security\Authentication\Token\OAuth2TokenFactory; use Trikoder\Bundle\OAuth2Bundle\OpenIDConnect\IdTokenResponse; final class TrikoderOAuth2Extension extends Extension implements PrependExtensionInterface, CompilerPassInterface { /** * {@inheritdoc} + * + * @throws Exception */ public function load(array $configs, ContainerBuilder $container) { @@ -60,6 +65,9 @@ public function load(array $configs, ContainerBuilder $container) $this->configureScopes($container, $config['scopes']); $this->configureOpenIDConnect($container, $config['openid_connect']); + $container->getDefinition(OAuth2TokenFactory::class) + ->setArgument(0, $config['role_prefix']); + $container->getDefinition(ConvertExceptionToResponseListener::class) ->addTag('kernel.event_listener', [ 'event' => KernelEvents::EXCEPTION, @@ -111,12 +119,7 @@ private function assertRequiredBundlesAreEnabled(ContainerBuilder $container): v foreach ($requiredBundles as $bundleAlias => $requiredBundle) { if (!$container->hasExtension($bundleAlias)) { - throw new LogicException( - sprintf( - 'Bundle \'%s\' needs to be enabled in your application kernel.', - $requiredBundle - ) - ); + throw new LogicException(sprintf('Bundle \'%s\' needs to be enabled in your application kernel.', $requiredBundle)); } } } @@ -135,7 +138,7 @@ private function configureAuthorizationServer(ContainerBuilder $container, array $authorizationServer->replaceArgument('$encryptionKey', $config['encryption_key']); } elseif ('defuse' === $config['encryption_key_type']) { if (!class_exists(Key::class)) { - throw new \RuntimeException('You must install the "defuse/php-encryption" package to use "encryption_key_type: defuse".'); + throw new RuntimeException('You must install the "defuse/php-encryption" package to use "encryption_key_type: defuse".'); } $keyDefinition = (new Definition(Key::class)) @@ -201,20 +204,26 @@ private function configureGrants(ContainerBuilder $container, array $config): vo ]) ; - $container - ->getDefinition(AuthCodeGrant::class) - ->replaceArgument('$authCodeTTL', new Definition(DateInterval::class, [$config['auth_code_ttl']])) + $authCodeGrantDefinition = $container->getDefinition(AuthCodeGrant::class); + $authCodeGrantDefinition->replaceArgument('$authCodeTTL', new Definition(DateInterval::class, [$config['auth_code_ttl']])) ->addMethodCall('setRefreshTokenTTL', [ new Definition(DateInterval::class, [$config['refresh_token_ttl']]), ]) ; + if (false === $config['require_code_challenge_for_public_clients']) { + $authCodeGrantDefinition->addMethodCall('disableRequireCodeChallengeForPublicClients'); + } + $container ->getDefinition(ImplicitGrant::class) ->replaceArgument('$accessTokenTTL', new Definition(DateInterval::class, [$config['access_token_ttl']])) ; } + /** + * @throws Exception + */ private function configurePersistence(LoaderInterface $loader, ContainerBuilder $container, array $config): void { if (\count($config) > 1) { @@ -289,7 +298,7 @@ private function configureScopes(ContainerBuilder $container, array $scopes): vo { $scopeManager = $container ->getDefinition( - $container->getAlias(ScopeManagerInterface::class) + (string) $container->getAlias(ScopeManagerInterface::class) ) ; diff --git a/Event/AuthorizationRequestResolveEvent.php b/Event/AuthorizationRequestResolveEvent.php index 06189f81..640ee6d3 100644 --- a/Event/AuthorizationRequestResolveEvent.php +++ b/Event/AuthorizationRequestResolveEvent.php @@ -7,11 +7,8 @@ use League\OAuth2\Server\RequestTypes\AuthorizationRequest; use LogicException; use Psr\Http\Message\ResponseInterface; -use RuntimeException; -use Symfony\Component\EventDispatcher\Event; use Symfony\Component\Security\Core\User\UserInterface; -use Trikoder\Bundle\OAuth2Bundle\Converter\ScopeConverterInterface; -use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; +use Symfony\Contracts\EventDispatcher\Event; use Trikoder\Bundle\OAuth2Bundle\Model\Client; use Trikoder\Bundle\OAuth2Bundle\Model\Scope; @@ -26,14 +23,14 @@ final class AuthorizationRequestResolveEvent extends Event private $authorizationRequest; /** - * @var ScopeConverterInterface + * @var Scope[] */ - private $scopeConverter; + private $scopes; /** - * @var ClientManagerInterface + * @var Client */ - private $clientManager; + private $client; /** * @var bool @@ -50,11 +47,14 @@ final class AuthorizationRequestResolveEvent extends Event */ private $user; - public function __construct(AuthorizationRequest $authorizationRequest, ScopeConverterInterface $scopeConverter, ClientManagerInterface $clientManager) + /** + * @param Scope[] $scopes + */ + public function __construct(AuthorizationRequest $authorizationRequest, array $scopes, Client $client) { $this->authorizationRequest = $authorizationRequest; - $this->scopeConverter = $scopeConverter; - $this->clientManager = $clientManager; + $this->scopes = $scopes; + $this->client = $client; } public function getAuthorizationResolution(): bool @@ -100,14 +100,7 @@ public function getGrantTypeId(): string public function getClient(): Client { - $identifier = $this->authorizationRequest->getClient()->getIdentifier(); - $client = $this->clientManager->find($identifier); - - if (null === $client) { - throw new RuntimeException(sprintf('No client found for the given identifier "%s".', $identifier)); - } - - return $client; + return $this->client; } public function getUser(): ?UserInterface @@ -127,9 +120,7 @@ public function setUser(?UserInterface $user): self */ public function getScopes(): array { - return $this->scopeConverter->toDomainArray( - $this->authorizationRequest->getScopes() - ); + return $this->scopes; } public function isAuthorizationApproved(): bool diff --git a/Event/AuthorizationRequestResolveEventFactory.php b/Event/AuthorizationRequestResolveEventFactory.php index 317bbf97..f213d4a1 100644 --- a/Event/AuthorizationRequestResolveEventFactory.php +++ b/Event/AuthorizationRequestResolveEventFactory.php @@ -5,6 +5,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Event; use League\OAuth2\Server\RequestTypes\AuthorizationRequest; +use RuntimeException; use Trikoder\Bundle\OAuth2Bundle\Converter\ScopeConverterInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; @@ -28,6 +29,14 @@ public function __construct(ScopeConverterInterface $scopeConverter, ClientManag public function fromAuthorizationRequest(AuthorizationRequest $authorizationRequest): AuthorizationRequestResolveEvent { - return new AuthorizationRequestResolveEvent($authorizationRequest, $this->scopeConverter, $this->clientManager); + $scopes = $this->scopeConverter->toDomainArray($authorizationRequest->getScopes()); + + $client = $this->clientManager->find($authorizationRequest->getClient()->getIdentifier()); + + if (null === $client) { + throw new RuntimeException(sprintf('No client found for the given identifier \'%s\'.', $authorizationRequest->getClient()->getIdentifier())); + } + + return new AuthorizationRequestResolveEvent($authorizationRequest, $scopes, $client); } } diff --git a/Event/ClaimsResolveEvent.php b/Event/ClaimsResolveEvent.php index 32effb4a..37c2625b 100644 --- a/Event/ClaimsResolveEvent.php +++ b/Event/ClaimsResolveEvent.php @@ -2,7 +2,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Event; -use Symfony\Component\EventDispatcher\Event; +use Symfony\Contracts\EventDispatcher\Event; final class ClaimsResolveEvent extends Event { diff --git a/Event/ScopeResolveEvent.php b/Event/ScopeResolveEvent.php index 4ff2c640..6f45c0a6 100644 --- a/Event/ScopeResolveEvent.php +++ b/Event/ScopeResolveEvent.php @@ -4,7 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Event; -use Symfony\Component\EventDispatcher\Event; +use Symfony\Contracts\EventDispatcher\Event; use Trikoder\Bundle\OAuth2Bundle\Model\Client; use Trikoder\Bundle\OAuth2Bundle\Model\Grant; use Trikoder\Bundle\OAuth2Bundle\Model\Scope; diff --git a/Event/UserResolveEvent.php b/Event/UserResolveEvent.php index 5526cfbe..b9a3db07 100644 --- a/Event/UserResolveEvent.php +++ b/Event/UserResolveEvent.php @@ -4,8 +4,8 @@ namespace Trikoder\Bundle\OAuth2Bundle\Event; -use Symfony\Component\EventDispatcher\Event; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Contracts\EventDispatcher\Event; use Trikoder\Bundle\OAuth2Bundle\Model\Client; use Trikoder\Bundle\OAuth2Bundle\Model\Grant; diff --git a/EventListener/ConvertExceptionToResponseListener.php b/EventListener/ConvertExceptionToResponseListener.php index a3fcaeaf..b29fdffe 100644 --- a/EventListener/ConvertExceptionToResponseListener.php +++ b/EventListener/ConvertExceptionToResponseListener.php @@ -5,7 +5,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\EventListener; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; +use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Trikoder\Bundle\OAuth2Bundle\Security\Exception\InsufficientScopesException; use Trikoder\Bundle\OAuth2Bundle\Security\Exception\Oauth2AuthenticationFailedException; @@ -14,9 +14,9 @@ */ final class ConvertExceptionToResponseListener { - public function onKernelException(GetResponseForExceptionEvent $event): void + public function onKernelException(ExceptionEvent $event): void { - $exception = $event->getException(); + $exception = $event->getThrowable(); if ($exception instanceof InsufficientScopesException || $exception instanceof Oauth2AuthenticationFailedException) { $event->setResponse(new Response($exception->getMessage(), $exception->getCode())); } diff --git a/League/Entity/Client.php b/League/Entity/Client.php index 4c3d2091..bff49c73 100644 --- a/League/Entity/Client.php +++ b/League/Entity/Client.php @@ -13,6 +13,11 @@ final class Client implements ClientEntityInterface use EntityTrait; use ClientTrait; + /** + * @var bool + */ + private $allowPlainTextPkce = false; + /** * {@inheritdoc} */ @@ -28,4 +33,19 @@ public function setRedirectUri(array $redirectUri): void { $this->redirectUri = $redirectUri; } + + public function setConfidential(bool $isConfidential): void + { + $this->isConfidential = $isConfidential; + } + + public function isPlainTextPkceAllowed(): bool + { + return $this->allowPlainTextPkce; + } + + public function setAllowPlainTextPkce(bool $allowPlainTextPkce): void + { + $this->allowPlainTextPkce = $allowPlainTextPkce; + } } diff --git a/League/Repository/AccessTokenRepository.php b/League/Repository/AccessTokenRepository.php index c293aa75..4ff70cd0 100644 --- a/League/Repository/AccessTokenRepository.php +++ b/League/Repository/AccessTokenRepository.php @@ -46,7 +46,15 @@ public function __construct( */ public function getNewToken(ClientEntityInterface $clientEntity, array $scopes, $userIdentifier = null) { - return new AccessTokenEntity(); + $accessToken = new AccessTokenEntity(); + $accessToken->setClient($clientEntity); + $accessToken->setUserIdentifier($userIdentifier); + + foreach ($scopes as $scope) { + $accessToken->addScope($scope); + } + + return $accessToken; } /** @@ -99,14 +107,12 @@ private function buildAccessTokenModel(AccessTokenEntityInterface $accessTokenEn { $client = $this->clientManager->find($accessTokenEntity->getClient()->getIdentifier()); - $accessToken = new AccessTokenModel( + return new AccessTokenModel( $accessTokenEntity->getIdentifier(), $accessTokenEntity->getExpiryDateTime(), $client, $accessTokenEntity->getUserIdentifier(), $this->scopeConverter->toDomainArray($accessTokenEntity->getScopes()) ); - - return $accessToken; } } diff --git a/League/Repository/AuthCodeRepository.php b/League/Repository/AuthCodeRepository.php index cb6316ff..c1ce8e29 100644 --- a/League/Repository/AuthCodeRepository.php +++ b/League/Repository/AuthCodeRepository.php @@ -91,7 +91,7 @@ public function revokeAuthCode($codeId) { $authorizationCode = $this->authorizationCodeManager->find($codeId); - if (null === $codeId) { + if (null === $authorizationCode) { return; } @@ -118,14 +118,12 @@ private function buildAuthorizationCode(AuthCode $authCode): AuthorizationCode { $client = $this->clientManager->find($authCode->getClient()->getIdentifier()); - $authorizationCode = new AuthorizationCode( + return new AuthorizationCode( $authCode->getIdentifier(), $authCode->getExpiryDateTime(), $client, $authCode->getUserIdentifier(), $this->scopeConverter->toDomainArray($authCode->getScopes()) ); - - return $authorizationCode; } } diff --git a/League/Repository/ClientRepository.php b/League/Repository/ClientRepository.php index 58ae6e55..2707ba3e 100644 --- a/League/Repository/ClientRepository.php +++ b/League/Repository/ClientRepository.php @@ -24,35 +24,41 @@ public function __construct(ClientManagerInterface $clientManager) /** * {@inheritdoc} */ - public function getClientEntity( - $clientIdentifier, - $grantType = null, - $clientSecret = null, - $mustValidateSecret = true - ) { + public function getClientEntity($clientIdentifier) + { $client = $this->clientManager->find($clientIdentifier); if (null === $client) { return null; } - if (!$client->isActive()) { - return null; + return $this->buildClientEntity($client); + } + + /** + * {@inheritdoc} + */ + public function validateClient($clientIdentifier, $clientSecret, $grantType) + { + $client = $this->clientManager->find($clientIdentifier); + + if (null === $client) { + return false; } - if (!$this->isGrantSupported($client, $grantType)) { - return null; + if (!$client->isActive()) { + return false; } - if (!$mustValidateSecret) { - return $this->buildClientEntity($client); + if (!$this->isGrantSupported($client, $grantType)) { + return false; } - if (!hash_equals($client->getSecret(), (string) $clientSecret)) { - return null; + if (!$client->isConfidential() || hash_equals($client->getSecret(), (string) $clientSecret)) { + return true; } - return $this->buildClientEntity($client); + return false; } private function buildClientEntity(ClientModel $client): ClientEntity @@ -60,6 +66,8 @@ private function buildClientEntity(ClientModel $client): ClientEntity $clientEntity = new ClientEntity(); $clientEntity->setIdentifier($client->getIdentifier()); $clientEntity->setRedirectUri(array_map('strval', $client->getRedirectUris())); + $clientEntity->setConfidential($client->isConfidential()); + $clientEntity->setAllowPlainTextPkce($client->isPlainTextPkceAllowed()); return $clientEntity; } diff --git a/League/Repository/RefreshTokenRepository.php b/League/Repository/RefreshTokenRepository.php index fc62dd1c..92b5ade7 100644 --- a/League/Repository/RefreshTokenRepository.php +++ b/League/Repository/RefreshTokenRepository.php @@ -90,12 +90,10 @@ private function buildRefreshTokenModel(RefreshTokenEntityInterface $refreshToke { $accessToken = $this->accessTokenManager->find($refreshTokenEntity->getAccessToken()->getIdentifier()); - $refreshToken = new RefreshTokenModel( + return new RefreshTokenModel( $refreshTokenEntity->getIdentifier(), $refreshTokenEntity->getExpiryDateTime(), $accessToken ); - - return $refreshToken; } } diff --git a/League/Repository/ScopeRepository.php b/League/Repository/ScopeRepository.php index 94c9cb62..f8936430 100644 --- a/League/Repository/ScopeRepository.php +++ b/League/Repository/ScopeRepository.php @@ -7,7 +7,7 @@ use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Trikoder\Bundle\OAuth2Bundle\Converter\ScopeConverterInterface; use Trikoder\Bundle\OAuth2Bundle\Event\ScopeResolveEvent; use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; @@ -79,7 +79,12 @@ public function finalizeScopes( $scopes = $this->setupScopes($client, $this->scopeConverter->toDomainArray($scopes)); $event = $this->eventDispatcher->dispatch( - new ScopeResolveEvent($scopes, new GrantModel($grantType), $client, $userIdentifier), + new ScopeResolveEvent( + $scopes, + new GrantModel($grantType), + $client, + $userIdentifier + ), OAuth2Events::SCOPE_RESOLVE ); diff --git a/League/Repository/UserRepository.php b/League/Repository/UserRepository.php index e765fdf2..bdacb26c 100644 --- a/League/Repository/UserRepository.php +++ b/League/Repository/UserRepository.php @@ -6,7 +6,7 @@ use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Repositories\UserRepositoryInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Trikoder\Bundle\OAuth2Bundle\Converter\UserConverterInterface; use Trikoder\Bundle\OAuth2Bundle\Event\UserResolveEvent; use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; @@ -52,7 +52,12 @@ public function getUserEntityByUserCredentials( $client = $this->clientManager->find($clientEntity->getIdentifier()); $event = $this->eventDispatcher->dispatch( - new UserResolveEvent($username, $password, new GrantModel($grantType), $client), + new UserResolveEvent( + $username, + $password, + new GrantModel($grantType), + $client + ), OAuth2Events::USER_RESOLVE ); diff --git a/Manager/AuthorizationCodeManagerInterface.php b/Manager/AuthorizationCodeManagerInterface.php index 4775c9d9..9454d595 100644 --- a/Manager/AuthorizationCodeManagerInterface.php +++ b/Manager/AuthorizationCodeManagerInterface.php @@ -11,4 +11,6 @@ interface AuthorizationCodeManagerInterface public function find(string $identifier): ?AuthorizationCode; public function save(AuthorizationCode $authCode): void; + + public function clearExpired(): int; } diff --git a/Manager/Doctrine/AccessTokenManager.php b/Manager/Doctrine/AccessTokenManager.php index 1192266e..41921af8 100644 --- a/Manager/Doctrine/AccessTokenManager.php +++ b/Manager/Doctrine/AccessTokenManager.php @@ -4,7 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine; -use DateTime; +use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken; @@ -43,7 +43,7 @@ public function clearExpired(): int return $this->entityManager->createQueryBuilder() ->delete(AccessToken::class, 'at') ->where('at.expiry < :expiry') - ->setParameter('expiry', new DateTime()) + ->setParameter('expiry', new DateTimeImmutable()) ->getQuery() ->execute(); } diff --git a/Manager/Doctrine/AuthorizationCodeManager.php b/Manager/Doctrine/AuthorizationCodeManager.php index a24124c8..b0aa7c3b 100644 --- a/Manager/Doctrine/AuthorizationCodeManager.php +++ b/Manager/Doctrine/AuthorizationCodeManager.php @@ -4,6 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine; +use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\AuthorizationCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationCode; @@ -36,4 +37,14 @@ public function save(AuthorizationCode $authorizationCode): void $this->entityManager->persist($authorizationCode); $this->entityManager->flush(); } + + public function clearExpired(): int + { + return $this->entityManager->createQueryBuilder() + ->delete(AuthorizationCode::class, 'ac') + ->where('ac.expiry < :expiry') + ->setParameter('expiry', new DateTimeImmutable()) + ->getQuery() + ->execute(); + } } diff --git a/Manager/Doctrine/RefreshTokenManager.php b/Manager/Doctrine/RefreshTokenManager.php index 96b92777..2f8172f2 100644 --- a/Manager/Doctrine/RefreshTokenManager.php +++ b/Manager/Doctrine/RefreshTokenManager.php @@ -4,7 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine; -use DateTime; +use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken; @@ -43,7 +43,7 @@ public function clearExpired(): int return $this->entityManager->createQueryBuilder() ->delete(RefreshToken::class, 'rt') ->where('rt.expiry < :expiry') - ->setParameter('expiry', new DateTime()) + ->setParameter('expiry', new DateTimeImmutable()) ->getQuery() ->execute(); } diff --git a/Manager/InMemory/AccessTokenManager.php b/Manager/InMemory/AccessTokenManager.php index 13a99eab..9fd7ff63 100644 --- a/Manager/InMemory/AccessTokenManager.php +++ b/Manager/InMemory/AccessTokenManager.php @@ -4,7 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Manager\InMemory; -use DateTime; +use DateTimeImmutable; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken; @@ -35,8 +35,8 @@ public function clearExpired(): int { $count = \count($this->accessTokens); - $now = new DateTime(); - $this->accessTokens = array_filter($this->accessTokens, function (AccessToken $accessToken) use ($now): bool { + $now = new DateTimeImmutable(); + $this->accessTokens = array_filter($this->accessTokens, static function (AccessToken $accessToken) use ($now): bool { return $accessToken->getExpiry() >= $now; }); diff --git a/Manager/InMemory/AuthorizationCodeManager.php b/Manager/InMemory/AuthorizationCodeManager.php index a51f36d1..f8d42b2b 100644 --- a/Manager/InMemory/AuthorizationCodeManager.php +++ b/Manager/InMemory/AuthorizationCodeManager.php @@ -4,6 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Manager\InMemory; +use DateTimeImmutable; use Trikoder\Bundle\OAuth2Bundle\Manager\AuthorizationCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationCode; @@ -23,4 +24,16 @@ public function save(AuthorizationCode $authorizationCode): void { $this->authorizationCodes[$authorizationCode->getIdentifier()] = $authorizationCode; } + + public function clearExpired(): int + { + $count = \count($this->authorizationCodes); + + $now = new DateTimeImmutable(); + $this->authorizationCodes = array_filter($this->authorizationCodes, static function (AuthorizationCode $authorizationCode) use ($now): bool { + return $authorizationCode->getExpiryDateTime() >= $now; + }); + + return $count - \count($this->authorizationCodes); + } } diff --git a/Manager/InMemory/ClientManager.php b/Manager/InMemory/ClientManager.php index c5d57ab2..10a5fbd9 100644 --- a/Manager/InMemory/ClientManager.php +++ b/Manager/InMemory/ClientManager.php @@ -48,7 +48,7 @@ public function list(?ClientFilter $clientFilter): array return $this->clients; } - return array_filter($this->clients, function (Client $client) use ($clientFilter): bool { + return array_filter($this->clients, static function (Client $client) use ($clientFilter): bool { $grantsPassed = self::passesFilter($client->getGrants(), $clientFilter->getGrants()); $scopesPassed = self::passesFilter($client->getScopes(), $clientFilter->getScopes()); $redirectUrisPassed = self::passesFilter($client->getRedirectUris(), $clientFilter->getRedirectUris()); diff --git a/Manager/InMemory/RefreshTokenManager.php b/Manager/InMemory/RefreshTokenManager.php index 02970601..be410b9b 100644 --- a/Manager/InMemory/RefreshTokenManager.php +++ b/Manager/InMemory/RefreshTokenManager.php @@ -4,7 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Manager\InMemory; -use DateTime; +use DateTimeImmutable; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken; @@ -35,8 +35,8 @@ public function clearExpired(): int { $count = \count($this->refreshTokens); - $now = new DateTime(); - $this->refreshTokens = array_filter($this->refreshTokens, function (RefreshToken $refreshToken) use ($now): bool { + $now = new DateTimeImmutable(); + $this->refreshTokens = array_filter($this->refreshTokens, static function (RefreshToken $refreshToken) use ($now): bool { return $refreshToken->getExpiry() >= $now; }); diff --git a/Model/Client.php b/Model/Client.php index 9cc742f0..06a4c55c 100644 --- a/Model/Client.php +++ b/Model/Client.php @@ -12,7 +12,7 @@ class Client private $identifier; /** - * @var string + * @var string|null */ private $secret; @@ -36,7 +36,12 @@ class Client */ private $active = true; - public function __construct(string $identifier, string $secret) + /** + * @var bool + */ + private $allowPlainTextPkce = false; + + public function __construct(string $identifier, ?string $secret) { $this->identifier = $identifier; $this->secret = $secret; @@ -52,7 +57,7 @@ public function getIdentifier(): string return $this->identifier; } - public function getSecret(): string + public function getSecret(): ?string { return $this->secret; } @@ -113,4 +118,21 @@ public function setActive(bool $active): self return $this; } + + public function isConfidential(): bool + { + return !empty($this->secret); + } + + public function isPlainTextPkceAllowed(): bool + { + return $this->allowPlainTextPkce; + } + + public function setAllowPlainTextPkce(bool $allowPlainTextPkce): self + { + $this->allowPlainTextPkce = $allowPlainTextPkce; + + return $this; + } } diff --git a/Model/Grant.php b/Model/Grant.php index 9203d22c..60aad4e6 100644 --- a/Model/Grant.php +++ b/Model/Grant.php @@ -17,9 +17,7 @@ class Grant public function __construct(string $grant) { if (!OAuth2Grants::has($grant)) { - throw new RuntimeException( - sprintf('The \'%s\' grant is not supported.', $grant) - ); + throw new RuntimeException(sprintf('The \'%s\' grant is not supported.', $grant)); } $this->grant = $grant; diff --git a/Model/RedirectUri.php b/Model/RedirectUri.php index 866690ec..707138de 100644 --- a/Model/RedirectUri.php +++ b/Model/RedirectUri.php @@ -16,9 +16,7 @@ class RedirectUri public function __construct(string $redirectUri) { if (!filter_var($redirectUri, FILTER_VALIDATE_URL)) { - throw new RuntimeException( - sprintf('The \'%s\' string is not a valid URI.', $redirectUri) - ); + throw new RuntimeException(sprintf('The \'%s\' string is not a valid URI.', $redirectUri)); } $this->redirectUri = $redirectUri; diff --git a/OpenIDConnect/Repository/IdentityProvider.php b/OpenIDConnect/Repository/IdentityProvider.php index 35f66658..b58710d5 100644 --- a/OpenIDConnect/Repository/IdentityProvider.php +++ b/OpenIDConnect/Repository/IdentityProvider.php @@ -26,8 +26,8 @@ public function getUserEntityByIdentifier($identifier) $user->setIdentifier($identifier); $event = $this->eventDispatcher->dispatch( - OAuth2Events::AUTHORIZATION_CLAIMS_RESOLVE, - new ClaimsResolveEvent($identifier) + new ClaimsResolveEvent($identifier), + OAuth2Events::AUTHORIZATION_CLAIMS_RESOLVE ); $user->setClaims($event->getClaims()); diff --git a/README.md b/README.md index b9022337..2a4b3668 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,7 @@ This package is currently in the active development. ## Requirements * [PHP 7.2](http://php.net/releases/7_2_0.php) or greater -* [Symfony 4.2](https://symfony.com/roadmap/4.2) or [Symfony 3.4](https://symfony.com/roadmap/3.4) -* [league/oauth2-server (versions >=7.2.0 <8.0)](https://packagist.org/packages/league/oauth2-server) +* [Symfony 4.4](https://symfony.com/roadmap/4.4) or [Symfony 5.x](https://symfony.com/roadmap/5.0) ## Installation @@ -85,6 +84,9 @@ This package is currently in the active development. # Whether to enable the authorization code grant enable_auth_code_grant: true + # Whether to require code challenge for public clients for the auth code grant + require_code_challenge_for_public_clients: true + # Whether to enable the implicit grant enable_implicit_grant: true resource_server: # Required @@ -108,6 +110,9 @@ This package is currently in the active development. # The priority of the event listener that converts an Exception to a Response exception_event_listener_priority: 10 + + # Set a custom prefix that replaces the default 'ROLE_OAUTH2_' role prefix + role_prefix: ROLE_OAUTH2_ ``` 1. Enable the bundle in `config/bundles.php` by adding it to the array: @@ -172,7 +177,7 @@ dev/bin/php composer install You can run the test suite using the following command: ```sh -dev/bin/php composer test +dev/bin/php-test composer test ``` ### Debugging diff --git a/Resources/config/doctrine/model/AccessToken.orm.xml b/Resources/config/doctrine/model/AccessToken.orm.xml index 247c68fe..192c6d9c 100644 --- a/Resources/config/doctrine/model/AccessToken.orm.xml +++ b/Resources/config/doctrine/model/AccessToken.orm.xml @@ -1,3 +1,5 @@ + + true - + - + diff --git a/Resources/config/doctrine/model/AuthorizationCode.orm.xml b/Resources/config/doctrine/model/AuthorizationCode.orm.xml index 844d5212..a5acc9c2 100644 --- a/Resources/config/doctrine/model/AuthorizationCode.orm.xml +++ b/Resources/config/doctrine/model/AuthorizationCode.orm.xml @@ -1,3 +1,5 @@ + + true - + diff --git a/Resources/config/doctrine/model/Client.orm.xml b/Resources/config/doctrine/model/Client.orm.xml index 804dffca..1c90b76a 100644 --- a/Resources/config/doctrine/model/Client.orm.xml +++ b/Resources/config/doctrine/model/Client.orm.xml @@ -1,13 +1,20 @@ + + - + + + + + + diff --git a/Resources/config/doctrine/model/RefreshToken.orm.xml b/Resources/config/doctrine/model/RefreshToken.orm.xml index 94e48f20..bcd9cefc 100644 --- a/Resources/config/doctrine/model/RefreshToken.orm.xml +++ b/Resources/config/doctrine/model/RefreshToken.orm.xml @@ -1,3 +1,5 @@ + + true - + diff --git a/Resources/config/routes.xml b/Resources/config/routes.xml index 930ea94f..091f1a90 100644 --- a/Resources/config/routes.xml +++ b/Resources/config/routes.xml @@ -1,3 +1,5 @@ + + + @@ -28,14 +30,14 @@ - + - + @@ -53,6 +55,7 @@ + @@ -63,6 +66,7 @@ + @@ -113,9 +117,10 @@ - + + @@ -210,6 +215,7 @@ + @@ -228,5 +234,9 @@ + + + + diff --git a/Resources/config/storage/doctrine.xml b/Resources/config/storage/doctrine.xml index 9e5214b9..90a7f617 100644 --- a/Resources/config/storage/doctrine.xml +++ b/Resources/config/storage/doctrine.xml @@ -1,3 +1,5 @@ + + diff --git a/Resources/config/storage/in_memory.xml b/Resources/config/storage/in_memory.xml index 10ef3f05..396b9fcb 100644 --- a/Resources/config/storage/in_memory.xml +++ b/Resources/config/storage/in_memory.xml @@ -1,3 +1,5 @@ + + diff --git a/Security/Authentication/Provider/OAuth2Provider.php b/Security/Authentication/Provider/OAuth2Provider.php index 2338bf3e..3528b2e7 100644 --- a/Security/Authentication/Provider/OAuth2Provider.php +++ b/Security/Authentication/Provider/OAuth2Provider.php @@ -13,6 +13,7 @@ use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; use Trikoder\Bundle\OAuth2Bundle\Security\Authentication\Token\OAuth2Token; +use Trikoder\Bundle\OAuth2Bundle\Security\Authentication\Token\OAuth2TokenFactory; final class OAuth2Provider implements AuthenticationProviderInterface { @@ -26,10 +27,26 @@ final class OAuth2Provider implements AuthenticationProviderInterface */ private $resourceServer; - public function __construct(UserProviderInterface $userProvider, ResourceServer $resourceServer) - { + /** + * @var OAuth2TokenFactory + */ + private $oauth2TokenFactory; + + /** + * @var string + */ + private $providerKey; + + public function __construct( + UserProviderInterface $userProvider, + ResourceServer $resourceServer, + OAuth2TokenFactory $oauth2TokenFactory, + string $providerKey + ) { $this->userProvider = $userProvider; $this->resourceServer = $resourceServer; + $this->oauth2TokenFactory = $oauth2TokenFactory; + $this->providerKey = $providerKey; } /** @@ -38,12 +55,7 @@ public function __construct(UserProviderInterface $userProvider, ResourceServer public function authenticate(TokenInterface $token) { if (!$this->supports($token)) { - throw new RuntimeException( - sprintf( - 'This authentication provider can only handle tokes of type \'%s\'.', - OAuth2Token::class - ) - ); + throw new RuntimeException(sprintf('This authentication provider can only handle tokes of type \'%s\'.', OAuth2Token::class)); } try { @@ -58,7 +70,7 @@ public function authenticate(TokenInterface $token) $request->getAttribute('oauth_user_id') ); - $token = new OAuth2Token($request, $user); + $token = $this->oauth2TokenFactory->createOAuth2Token($request, $user, $this->providerKey); $token->setAuthenticated(true); return $token; @@ -69,7 +81,7 @@ public function authenticate(TokenInterface $token) */ public function supports(TokenInterface $token) { - return $token instanceof OAuth2Token; + return $token instanceof OAuth2Token && $this->providerKey === $token->getProviderKey(); } private function getAuthenticatedUser(string $userIdentifier): ?UserInterface diff --git a/Security/Authentication/Token/OAuth2Token.php b/Security/Authentication/Token/OAuth2Token.php index 6b873128..d368e933 100644 --- a/Security/Authentication/Token/OAuth2Token.php +++ b/Security/Authentication/Token/OAuth2Token.php @@ -10,9 +10,19 @@ final class OAuth2Token extends AbstractToken { - public function __construct(ServerRequestInterface $serverRequest, ?UserInterface $user) - { + /** + * @var string + */ + private $providerKey; + + public function __construct( + ServerRequestInterface $serverRequest, + ?UserInterface $user, + string $rolePrefix, + string $providerKey + ) { $this->setAttribute('server_request', $serverRequest); + $this->setAttribute('role_prefix', $rolePrefix); $roles = $this->buildRolesFromScopes(); @@ -24,6 +34,8 @@ public function __construct(ServerRequestInterface $serverRequest, ?UserInterfac } parent::__construct(array_unique($roles)); + + $this->providerKey = $providerKey; } /** @@ -34,12 +46,29 @@ public function getCredentials() return $this->getAttribute('server_request')->getAttribute('oauth_access_token_id'); } + public function getProviderKey(): string + { + return $this->providerKey; + } + + public function __serialize(): array + { + return [$this->providerKey, parent::__serialize()]; + } + + public function __unserialize(array $data): void + { + [$this->providerKey, $parentData] = $data; + parent::__unserialize($parentData); + } + private function buildRolesFromScopes(): array { + $prefix = $this->getAttribute('role_prefix'); $roles = []; foreach ($this->getAttribute('server_request')->getAttribute('oauth_scopes', []) as $scope) { - $roles[] = sprintf('ROLE_OAUTH2_%s', trim(strtoupper($scope))); + $roles[] = strtoupper(trim($prefix . $scope)); } return $roles; diff --git a/Security/Authentication/Token/OAuth2TokenFactory.php b/Security/Authentication/Token/OAuth2TokenFactory.php new file mode 100644 index 00000000..61419706 --- /dev/null +++ b/Security/Authentication/Token/OAuth2TokenFactory.php @@ -0,0 +1,26 @@ +rolePrefix = $rolePrefix; + } + + public function createOAuth2Token(ServerRequestInterface $serverRequest, ?UserInterface $user, string $providerKey): OAuth2Token + { + return new OAuth2Token($serverRequest, $user, $this->rolePrefix, $providerKey); + } +} diff --git a/Security/EntryPoint/OAuth2EntryPoint.php b/Security/EntryPoint/OAuth2EntryPoint.php index 3186ea87..366cae1a 100644 --- a/Security/EntryPoint/OAuth2EntryPoint.php +++ b/Security/EntryPoint/OAuth2EntryPoint.php @@ -15,7 +15,7 @@ final class OAuth2EntryPoint implements AuthenticationEntryPointInterface /** * {@inheritdoc} */ - public function start(Request $request, AuthenticationException $authException = null) + public function start(Request $request, ?AuthenticationException $authException = null) { $exception = new UnauthorizedHttpException('Bearer'); diff --git a/Security/Firewall/OAuth2Listener.php b/Security/Firewall/OAuth2Listener.php index 94ef9639..618cdc51 100644 --- a/Security/Firewall/OAuth2Listener.php +++ b/Security/Firewall/OAuth2Listener.php @@ -6,16 +6,16 @@ use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Event\GetResponseEvent; +use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Http\Firewall\ListenerInterface; use Trikoder\Bundle\OAuth2Bundle\Security\Authentication\Token\OAuth2Token; +use Trikoder\Bundle\OAuth2Bundle\Security\Authentication\Token\OAuth2TokenFactory; use Trikoder\Bundle\OAuth2Bundle\Security\Exception\InsufficientScopesException; use Trikoder\Bundle\OAuth2Bundle\Security\Exception\Oauth2AuthenticationFailedException; -final class OAuth2Listener implements ListenerInterface +final class OAuth2Listener { /** * @var TokenStorageInterface @@ -32,25 +32,31 @@ final class OAuth2Listener implements ListenerInterface */ private $httpMessageFactory; + /** + * @var OAuth2TokenFactory + */ + private $oauth2TokenFactory; + + /** + * @var string + */ + private $providerKey; + public function __construct( TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, - HttpMessageFactoryInterface $httpMessageFactory + HttpMessageFactoryInterface $httpMessageFactory, + OAuth2TokenFactory $oauth2TokenFactory, + string $providerKey ) { $this->tokenStorage = $tokenStorage; $this->authenticationManager = $authenticationManager; $this->httpMessageFactory = $httpMessageFactory; + $this->oauth2TokenFactory = $oauth2TokenFactory; + $this->providerKey = $providerKey; } - /** - * BC layer for Symfony < 4.3 - */ - public function handle(GetResponseEvent $event) - { - $this->__invoke($event); - } - - public function __invoke(GetResponseEvent $event) + public function __invoke(RequestEvent $event) { $request = $this->httpMessageFactory->createRequest($event->getRequest()); @@ -60,7 +66,7 @@ public function __invoke(GetResponseEvent $event) try { /** @var OAuth2Token $authenticatedToken */ - $authenticatedToken = $this->authenticationManager->authenticate(new OAuth2Token($request, null)); + $authenticatedToken = $this->authenticationManager->authenticate($this->oauth2TokenFactory->createOAuth2Token($request, null, $this->providerKey)); } catch (AuthenticationException $e) { throw Oauth2AuthenticationFailedException::create($e->getMessage()); } diff --git a/Service/BCEventDispatcher.php b/Service/BCEventDispatcher.php deleted file mode 100644 index c70bbca4..00000000 --- a/Service/BCEventDispatcher.php +++ /dev/null @@ -1,39 +0,0 @@ - - */ -class BCEventDispatcher implements ContractsEventDispatcher -{ - private $eventDispatcher; - - public function __construct(LegacyEventDispatcher $eventDispatcher) - { - $this->eventDispatcher = $eventDispatcher; - } - - /** - * This method is only used in this bundle. We will always call dispatch(object, string) - */ - public function dispatch($event/*, string $eventName = null*/) - { - $eventName = 1 < \func_num_args() ? func_get_arg(1) : null; - $eventName = $eventName ?? \get_class($event); - - return $this->eventDispatcher->dispatch($eventName, $event); - } - - public function addListener($eventName, $listener, $priority = 0) - { - return $this->eventDispatcher->addListener($eventName, $listener, $priority); - } -} diff --git a/Tests/Acceptance/AbstractAcceptanceTest.php b/Tests/Acceptance/AbstractAcceptanceTest.php index bdbdac06..3d5d0bc5 100644 --- a/Tests/Acceptance/AbstractAcceptanceTest.php +++ b/Tests/Acceptance/AbstractAcceptanceTest.php @@ -4,8 +4,8 @@ namespace Trikoder\Bundle\OAuth2Bundle\Tests\Acceptance; -use Symfony\Bundle\FrameworkBundle\Client; use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Trikoder\Bundle\OAuth2Bundle\Tests\TestHelper; @@ -17,12 +17,13 @@ abstract class AbstractAcceptanceTest extends WebTestCase protected $application; /** - * @var Client + * @var KernelBrowser */ protected $client; protected function setUp(): void { + ini_set('memory_limit', '256M'); $this->client = self::createClient(); $this->application = new Application($this->client->getKernel()); diff --git a/Tests/Acceptance/AuthorizationEndpointTest.php b/Tests/Acceptance/AuthorizationEndpointTest.php index ed4198ce..304bac7a 100644 --- a/Tests/Acceptance/AuthorizationEndpointTest.php +++ b/Tests/Acceptance/AuthorizationEndpointTest.php @@ -4,16 +4,18 @@ namespace Trikoder\Bundle\OAuth2Bundle\Tests\Acceptance; -use DateTime; +use DateTimeImmutable; +use Nyholm\Psr7\Response; use Trikoder\Bundle\OAuth2Bundle\Event\AuthorizationRequestResolveEvent; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\AuthorizationCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationCode; use Trikoder\Bundle\OAuth2Bundle\OAuth2Events; use Trikoder\Bundle\OAuth2Bundle\Tests\Fixtures\FixtureFactory; -use Zend\Diactoros\Response; +use Trikoder\Bundle\OAuth2Bundle\Tests\TestHelper; final class AuthorizationEndpointTest extends AbstractAcceptanceTest { @@ -35,11 +37,11 @@ public function testSuccessfulCodeRequest(): void $this->client ->getContainer() ->get('event_dispatcher') - ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event): void { + ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, static function (AuthorizationRequestResolveEvent $event): void { $event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED); }); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $this->client->request( @@ -68,16 +70,240 @@ public function testSuccessfulCodeRequest(): void $this->assertEquals('foobar', $query['state']); } - public function testSuccessfulTokenRequest(): void + public function testSuccessfulPKCEAuthCodeRequest(): void + { + $state = bin2hex(random_bytes(20)); + $codeVerifier = bin2hex(random_bytes(64)); + $codeChallengeMethod = 'S256'; + + $codeChallenge = strtr( + rtrim(base64_encode(hash('sha256', $codeVerifier, true)), '='), + '+/', + '-_' + ); + + $this->client + ->getContainer() + ->get('event_dispatcher') + ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event) use ($state, $codeChallenge, $codeChallengeMethod): void { + $this->assertSame($state, $event->getState()); + $this->assertSame($codeChallenge, $event->getCodeChallenge()); + $this->assertSame($codeChallengeMethod, $event->getCodeChallengeMethod()); + + $event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED); + }); + + timecop_freeze(new DateTimeImmutable()); + + try { + $this->client->request( + 'GET', + '/authorize', + [ + 'client_id' => FixtureFactory::FIXTURE_PUBLIC_CLIENT, + 'response_type' => 'code', + 'scope' => '', + 'state' => $state, + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => $codeChallengeMethod, + ] + ); + } finally { + timecop_return(); + } + + $response = $this->client->getResponse(); + + $this->assertSame(302, $response->getStatusCode()); + $redirectUri = $response->headers->get('Location'); + + $this->assertStringStartsWith(FixtureFactory::FIXTURE_CLIENT_FIRST_REDIRECT_URI, $redirectUri); + $query = []; + parse_str(parse_url($redirectUri, PHP_URL_QUERY), $query); + $this->assertArrayHasKey('state', $query); + $this->assertSame($state, $query['state']); + + $this->assertArrayHasKey('code', $query); + $payload = json_decode(TestHelper::decryptPayload($query['code']), true); + + $this->assertArrayHasKey('code_challenge', $payload); + $this->assertArrayHasKey('code_challenge_method', $payload); + $this->assertSame($codeChallenge, $payload['code_challenge']); + $this->assertSame($codeChallengeMethod, $payload['code_challenge_method']); + + /** @var AuthorizationCode|null $authCode */ + $authCode = $this->client + ->getContainer() + ->get('doctrine.orm.entity_manager') + ->getRepository(AuthorizationCode::class) + ->findOneBy(['identifier' => $payload['auth_code_id']]); + + $this->assertInstanceOf(AuthorizationCode::class, $authCode); + $this->assertSame(FixtureFactory::FIXTURE_PUBLIC_CLIENT, $authCode->getClient()->getIdentifier()); + } + + public function testAuthCodeRequestWithPublicClientWithoutCodeChallengeWhenTheChallengeIsRequiredForPublicClients(): void { $this->client ->getContainer() ->get('event_dispatcher') ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event): void { + $this->fail('This event should not have been dispatched.'); + }); + + timecop_freeze(new DateTimeImmutable()); + + try { + $this->client->request( + 'GET', + '/authorize', + [ + 'client_id' => FixtureFactory::FIXTURE_PUBLIC_CLIENT, + 'response_type' => 'code', + 'scope' => '', + 'state' => bin2hex(random_bytes(20)), + ] + ); + } finally { + timecop_return(); + } + + $response = $this->client->getResponse(); + + $this->assertSame(400, $response->getStatusCode()); + + $this->assertSame('application/json', $response->headers->get('Content-Type')); + + $jsonResponse = json_decode($response->getContent(), true); + + $this->assertSame('invalid_request', $jsonResponse['error']); + $this->assertSame('The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.', $jsonResponse['message']); + $this->assertSame('Code challenge must be provided for public clients', $jsonResponse['hint']); + } + + public function testAuthCodeRequestWithClientWhoIsNotAllowedToMakeARequestWithPlainCodeChallengeMethod(): void + { + $state = bin2hex(random_bytes(20)); + $codeVerifier = bin2hex(random_bytes(32)); + $codeChallengeMethod = 'plain'; + $codeChallenge = strtr(rtrim(base64_encode($codeVerifier), '='), '+/', '-_'); + + $this->client + ->getContainer() + ->get('event_dispatcher') + ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event): void { + $this->fail('This event should not have been dispatched.'); + }); + + timecop_freeze(new DateTimeImmutable()); + + try { + $this->client->request( + 'GET', + '/authorize', + [ + 'client_id' => FixtureFactory::FIXTURE_PUBLIC_CLIENT, + 'response_type' => 'code', + 'scope' => '', + 'state' => $state, + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => $codeChallengeMethod, + ] + ); + } finally { + timecop_return(); + } + + $response = $this->client->getResponse(); + + $this->assertSame(400, $response->getStatusCode()); + + $this->assertSame('application/json', $response->headers->get('Content-Type')); + + $jsonResponse = json_decode($response->getContent(), true); + + $this->assertSame('invalid_request', $jsonResponse['error']); + $this->assertSame('The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.', $jsonResponse['message']); + $this->assertSame('Plain code challenge method is not allowed for this client', $jsonResponse['hint']); + } + + public function testAuthCodeRequestWithClientWhoIsAllowedToMakeARequestWithPlainCodeChallengeMethod(): void + { + $state = bin2hex(random_bytes(20)); + $codeVerifier = bin2hex(random_bytes(32)); + $codeChallengeMethod = 'plain'; + $codeChallenge = strtr(rtrim(base64_encode($codeVerifier), '='), '+/', '-_'); + + $this->client + ->getContainer() + ->get('event_dispatcher') + ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event) use ($state, $codeChallenge, $codeChallengeMethod): void { + $this->assertSame($state, $event->getState()); + $this->assertSame($codeChallenge, $event->getCodeChallenge()); + $this->assertSame($codeChallengeMethod, $event->getCodeChallengeMethod()); + $event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED); }); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); + + try { + $this->client->request( + 'GET', + '/authorize', + [ + 'client_id' => FixtureFactory::FIXTURE_PUBLIC_CLIENT_ALLOWED_TO_USE_PLAIN_CHALLENGE_METHOD, + 'response_type' => 'code', + 'scope' => '', + 'state' => $state, + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => $codeChallengeMethod, + ] + ); + } finally { + timecop_return(); + } + + $response = $this->client->getResponse(); + + $this->assertSame(302, $response->getStatusCode()); + $redirectUri = $response->headers->get('Location'); + + $this->assertStringStartsWith(FixtureFactory::FIXTURE_PUBLIC_CLIENT_ALLOWED_TO_USE_PLAIN_CHALLENGE_METHOD_REDIRECT_URI, $redirectUri); + $query = []; + parse_str(parse_url($redirectUri, PHP_URL_QUERY), $query); + $this->assertArrayHasKey('state', $query); + $this->assertSame($state, $query['state']); + + $this->assertArrayHasKey('code', $query); + $payload = json_decode(TestHelper::decryptPayload($query['code']), true); + + $this->assertArrayHasKey('code_challenge', $payload); + $this->assertArrayHasKey('code_challenge_method', $payload); + $this->assertSame($codeChallenge, $payload['code_challenge']); + $this->assertSame($codeChallengeMethod, $payload['code_challenge_method']); + + /** @var AuthorizationCode|null $authCode */ + $authCode = $this->client + ->getContainer() + ->get('doctrine.orm.entity_manager') + ->getRepository(AuthorizationCode::class) + ->findOneBy(['identifier' => $payload['auth_code_id']]); + + $this->assertInstanceOf(AuthorizationCode::class, $authCode); + $this->assertSame(FixtureFactory::FIXTURE_PUBLIC_CLIENT_ALLOWED_TO_USE_PLAIN_CHALLENGE_METHOD, $authCode->getClient()->getIdentifier()); + } + + public function testSuccessfulTokenRequest(): void + { + $this->client + ->getContainer() + ->get('event_dispatcher') + ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, static function (AuthorizationRequestResolveEvent $event): void { + $event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED); + }); + + timecop_freeze(new DateTimeImmutable()); try { $this->client->request( @@ -113,12 +339,12 @@ public function testCodeRequestRedirectToResolutionUri(): void $this->client ->getContainer() ->get('event_dispatcher') - ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event): void { + ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, static function (AuthorizationRequestResolveEvent $event): void { $response = (new Response())->withStatus(302)->withHeader('Location', '/authorize/consent'); $event->setResponse($response); }); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $this->client->request( @@ -148,15 +374,15 @@ public function testAuthorizationRequestEventIsStoppedAfterSettingAResponse(): v $eventDispatcher = $this->client ->getContainer() ->get('event_dispatcher'); - $eventDispatcher->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event): void { + $eventDispatcher->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, static function (AuthorizationRequestResolveEvent $event): void { $event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED); }, 100); - $eventDispatcher->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event): void { + $eventDispatcher->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, static function (AuthorizationRequestResolveEvent $event): void { $response = (new Response())->withStatus(302)->withHeader('Location', '/authorize/consent'); $event->setResponse($response); }, 200); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $this->client->request( @@ -184,15 +410,15 @@ public function testAuthorizationRequestEventIsStoppedAfterResolution(): void $eventDispatcher = $this->client ->getContainer() ->get('event_dispatcher'); - $eventDispatcher->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event): void { + $eventDispatcher->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, static function (AuthorizationRequestResolveEvent $event): void { $event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED); }, 200); - $eventDispatcher->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event): void { + $eventDispatcher->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, static function (AuthorizationRequestResolveEvent $event): void { $response = (new Response())->withStatus(302)->withHeader('Location', '/authorize/consent'); $event->setResponse($response); }, 100); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $this->client->request( @@ -226,11 +452,11 @@ public function testFailedCodeRequestRedirectWithFakedRedirectUri(): void $this->client ->getContainer() ->get('event_dispatcher') - ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event): void { + ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, static function (AuthorizationRequestResolveEvent $event): void { $event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED); }); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $this->client->request( diff --git a/Tests/Acceptance/ClearExpiredTokensCommandTest.php b/Tests/Acceptance/ClearExpiredTokensCommandTest.php index abdd198a..5d0f02fb 100644 --- a/Tests/Acceptance/ClearExpiredTokensCommandTest.php +++ b/Tests/Acceptance/ClearExpiredTokensCommandTest.php @@ -4,7 +4,8 @@ namespace Trikoder\Bundle\OAuth2Bundle\Tests\Acceptance; -use DateTime; +use DateTimeImmutable; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; @@ -12,6 +13,9 @@ use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken; +use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationCode; +use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken; use Trikoder\Bundle\OAuth2Bundle\Tests\Fixtures\FixtureFactory; final class ClearExpiredTokensCommandTest extends AbstractAcceptanceTest @@ -20,7 +24,7 @@ protected function setUp(): void { parent::setUp(); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); FixtureFactory::initializeFixtures( $this->client->getContainer()->get(ScopeManagerInterface::class), @@ -38,7 +42,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testClearExpiredAccessAndRefreshTokens(): void + public function testClearExpiredAccessAndRefreshTokensAndAuthCodes(): void { $command = $this->command(); $commandTester = new CommandTester($command); @@ -52,6 +56,27 @@ public function testClearExpiredAccessAndRefreshTokens(): void $output = $commandTester->getDisplay(); $this->assertStringContainsString('Cleared 1 expired access token.', $output); $this->assertStringContainsString('Cleared 1 expired refresh token.', $output); + $this->assertStringContainsString('Cleared 1 expired auth code.', $output); + + /** @var EntityManagerInterface $em */ + $em = $this->client->getContainer()->get('doctrine.orm.entity_manager'); + $em->clear(); + + $this->assertNull( + $this->client->getContainer()->get(AccessTokenManagerInterface::class)->find( + FixtureFactory::FIXTURE_ACCESS_TOKEN_EXPIRED + ) + ); + $this->assertNull( + $this->client->getContainer()->get(RefreshTokenManagerInterface::class)->find( + FixtureFactory::FIXTURE_REFRESH_TOKEN_EXPIRED + ) + ); + $this->assertNull( + $this->client->getContainer()->get(AuthorizationCodeManagerInterface::class)->find( + FixtureFactory::FIXTURE_AUTH_CODE_EXPIRED + ) + ); } public function testClearExpiredAccessTokens(): void @@ -61,7 +86,7 @@ public function testClearExpiredAccessTokens(): void $exitCode = $commandTester->execute([ 'command' => $command->getName(), - '--access-tokens-only' => true, + '--access-tokens' => true, ]); $this->assertSame(0, $exitCode); @@ -69,6 +94,29 @@ public function testClearExpiredAccessTokens(): void $output = $commandTester->getDisplay(); $this->assertStringContainsString('Cleared 1 expired access token.', $output); $this->assertStringNotContainsString('Cleared 1 expired refresh token.', $output); + $this->assertStringNotContainsString('Cleared 1 expired auth code.', $output); + + /** @var EntityManagerInterface $em */ + $em = $this->client->getContainer()->get('doctrine.orm.entity_manager'); + $em->clear(); + + $this->assertNull( + $this->client->getContainer()->get(AccessTokenManagerInterface::class)->find( + FixtureFactory::FIXTURE_ACCESS_TOKEN_EXPIRED + ) + ); + $this->assertInstanceOf( + RefreshToken::class, + $this->client->getContainer()->get(RefreshTokenManagerInterface::class)->find( + FixtureFactory::FIXTURE_REFRESH_TOKEN_EXPIRED + ) + ); + $this->assertInstanceOf( + AuthorizationCode::class, + $this->client->getContainer()->get(AuthorizationCodeManagerInterface::class)->find( + FixtureFactory::FIXTURE_AUTH_CODE_EXPIRED + ) + ); } public function testClearExpiredRefreshTokens(): void @@ -78,7 +126,7 @@ public function testClearExpiredRefreshTokens(): void $exitCode = $commandTester->execute([ 'command' => $command->getName(), - '--refresh-tokens-only' => true, + '--refresh-tokens' => true, ]); $this->assertSame(0, $exitCode); @@ -86,23 +134,69 @@ public function testClearExpiredRefreshTokens(): void $output = $commandTester->getDisplay(); $this->assertStringNotContainsString('Cleared 1 expired access token.', $output); $this->assertStringContainsString('Cleared 1 expired refresh token.', $output); + $this->assertStringNotContainsString('Cleared 1 expired auth code.', $output); + + /** @var EntityManagerInterface $em */ + $em = $this->client->getContainer()->get('doctrine.orm.entity_manager'); + $em->clear(); + + $this->assertInstanceOf( + AccessToken::class, + $this->client->getContainer()->get(AccessTokenManagerInterface::class)->find( + FixtureFactory::FIXTURE_ACCESS_TOKEN_EXPIRED + ) + ); + $this->assertNull( + $this->client->getContainer()->get(RefreshTokenManagerInterface::class)->find( + FixtureFactory::FIXTURE_REFRESH_TOKEN_EXPIRED + ) + ); + $this->assertInstanceOf( + AuthorizationCode::class, + $this->client->getContainer()->get(AuthorizationCodeManagerInterface::class)->find( + FixtureFactory::FIXTURE_AUTH_CODE_EXPIRED + ) + ); } - public function testErrorWhenBothOptionsAreUsed(): void + public function testClearExpiredAuthCodes(): void { $command = $this->command(); $commandTester = new CommandTester($command); $exitCode = $commandTester->execute([ 'command' => $command->getName(), - '--access-tokens-only' => true, - '--refresh-tokens-only' => true, + '--auth-codes' => true, ]); - $this->assertSame(1, $exitCode); + $this->assertSame(0, $exitCode); $output = $commandTester->getDisplay(); - $this->assertStringContainsString('Please choose only one of the following options:', $output); + $this->assertStringNotContainsString('Cleared 1 expired access token.', $output); + $this->assertStringNotContainsString('Cleared 1 expired refresh token.', $output); + $this->assertStringContainsString('Cleared 1 expired auth code.', $output); + + /** @var EntityManagerInterface $em */ + $em = $this->client->getContainer()->get('doctrine.orm.entity_manager'); + $em->clear(); + + $this->assertInstanceOf( + AccessToken::class, + $this->client->getContainer()->get(AccessTokenManagerInterface::class)->find( + FixtureFactory::FIXTURE_ACCESS_TOKEN_EXPIRED + ) + ); + $this->assertInstanceOf( + RefreshToken::class, + $this->client->getContainer()->get(RefreshTokenManagerInterface::class)->find( + FixtureFactory::FIXTURE_REFRESH_TOKEN_EXPIRED + ) + ); + $this->assertNull( + $this->client->getContainer()->get(AuthorizationCodeManagerInterface::class)->find( + FixtureFactory::FIXTURE_AUTH_CODE_EXPIRED + ) + ); } private function command(): Command diff --git a/Tests/Acceptance/CreateClientCommandTest.php b/Tests/Acceptance/CreateClientCommandTest.php index de3a9da4..166a8480 100644 --- a/Tests/Acceptance/CreateClientCommandTest.php +++ b/Tests/Acceptance/CreateClientCommandTest.php @@ -35,11 +35,68 @@ public function testCreateClientWithIdentifier(): void $this->assertStringContainsString('New oAuth2 client created successfully', $output); $this->assertStringContainsString('foobar', $output); + /** @var Client $client */ $client = $this->client ->getContainer() ->get(ClientManagerInterface::class) ->find('foobar'); $this->assertInstanceOf(Client::class, $client); + $this->assertTrue($client->isConfidential()); + $this->assertNotEmpty($client->getSecret()); + $this->assertFalse($client->isPlainTextPkceAllowed()); + } + + public function testCreatePublicClientWithIdentifier(): void + { + $clientIdentifier = 'foobar test'; + $command = $this->application->find('trikoder:oauth2:create-client'); + $commandTester = new CommandTester($command); + $commandTester->execute([ + 'command' => $command->getName(), + 'identifier' => $clientIdentifier, + '--public' => true, + ]); + + $this->assertSame(0, $commandTester->getStatusCode()); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('New oAuth2 client created successfully', $output); + $this->assertStringContainsString($clientIdentifier, $output); + + /** @var Client $client */ + $client = $this->client + ->getContainer() + ->get(ClientManagerInterface::class) + ->find($clientIdentifier); + $this->assertInstanceOf(Client::class, $client); + $this->assertFalse($client->isConfidential()); + $this->assertNull($client->getSecret()); + $this->assertFalse($client->isPlainTextPkceAllowed()); + } + + public function testCannotCreatePublicClientWithSecret(): void + { + $clientIdentifier = 'foobar test'; + $command = $this->application->find('trikoder:oauth2:create-client'); + $commandTester = new CommandTester($command); + $commandTester->execute([ + 'command' => $command->getName(), + 'identifier' => $clientIdentifier, + 'secret' => 'foo', + '--public' => true, + ]); + + $this->assertSame(1, $commandTester->getStatusCode()); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('The client cannot have a secret and be public.', $output); + $this->assertStringNotContainsString($clientIdentifier, $output); + + $client = $this->client + ->getContainer() + ->get(ClientManagerInterface::class) + ->find($clientIdentifier); + $this->assertNull($client); } public function testCreateClientWithSecret(): void @@ -54,12 +111,38 @@ public function testCreateClientWithSecret(): void $output = $commandTester->getDisplay(); $this->assertStringContainsString('New oAuth2 client created successfully', $output); + + /** @var Client $client */ $client = $this->client ->getContainer() ->get(ClientManagerInterface::class) ->find('foobar'); $this->assertInstanceOf(Client::class, $client); $this->assertSame('quzbaz', $client->getSecret()); + $this->assertTrue($client->isConfidential()); + $this->assertFalse($client->isPlainTextPkceAllowed()); + } + + public function testCreateClientWhoIsAllowedToUsePlainPkceChallengeMethod(): void + { + $command = $this->application->find('trikoder:oauth2:create-client'); + $commandTester = new CommandTester($command); + $commandTester->execute([ + 'command' => $command->getName(), + 'identifier' => 'foobar-123', + '--allow-plain-text-pkce' => true, + ]); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('New oAuth2 client created successfully', $output); + + /** @var Client $client */ + $client = $this->client + ->getContainer() + ->get(ClientManagerInterface::class) + ->find('foobar-123'); + $this->assertInstanceOf(Client::class, $client); + $this->assertTrue($client->isPlainTextPkceAllowed()); } public function testCreateClientWithRedirectUris(): void diff --git a/Tests/Acceptance/DeleteClientCommandTest.php b/Tests/Acceptance/DeleteClientCommandTest.php index de132f19..eda37db7 100644 --- a/Tests/Acceptance/DeleteClientCommandTest.php +++ b/Tests/Acceptance/DeleteClientCommandTest.php @@ -9,6 +9,9 @@ use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\Client; +/** + * @covers \Trikoder\Bundle\OAuth2Bundle\Command\DeleteClientCommand + */ final class DeleteClientCommandTest extends AbstractAcceptanceTest { public function testDeleteClient(): void @@ -42,27 +45,27 @@ public function testDeleteNonExistentClient(): void $this->assertStringContainsString(sprintf('oAuth2 client identified as "%s" does not exist', $identifierName), $output); } - private function findClient($identifier): ?Client + private function findClient(string $identifier): ?Client { return $this - ->client - ->getContainer() - ->get(ClientManagerInterface::class) + ->getClientManager() ->find($identifier) ; } - private function fakeAClient($identifier): Client + private function fakeAClient(string $identifier): Client { return new Client($identifier, 'quzbaz'); } private function getClientManager(): ClientManagerInterface { - return $this->client - ->getContainer() - ->get(ClientManagerInterface::class) + return + $this + ->client + ->getContainer() + ->get(ClientManagerInterface::class) ; } diff --git a/Tests/Acceptance/DoctrineAccessTokenManagerTest.php b/Tests/Acceptance/DoctrineAccessTokenManagerTest.php index b0194a93..c961d1c2 100644 --- a/Tests/Acceptance/DoctrineAccessTokenManagerTest.php +++ b/Tests/Acceptance/DoctrineAccessTokenManagerTest.php @@ -2,22 +2,24 @@ declare(strict_types=1); -namespace Trikoder\Bundle\OAuth2Bundle\Tests\Unit; +namespace Trikoder\Bundle\OAuth2Bundle\Tests\Acceptance; -use DateTime; +use DateTimeImmutable; +use Doctrine\ORM\EntityManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine\AccessTokenManager as DoctrineAccessTokenManager; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken; use Trikoder\Bundle\OAuth2Bundle\Model\Client; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken; -use Trikoder\Bundle\OAuth2Bundle\Tests\Acceptance\AbstractAcceptanceTest; /** - * @TODO This should be in the Integration tests folder but the current tests infrastructure would need improvements first. + * @TODO This should be in the Integration tests folder but the current tests infrastructure would need improvements first. + * @covers \Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine\AccessTokenManager */ final class DoctrineAccessTokenManagerTest extends AbstractAcceptanceTest { public function testClearExpired(): void { + /** @var EntityManagerInterface $em */ $em = $this->client->getContainer()->get('doctrine.orm.entity_manager'); $doctrineAccessTokenManager = new DoctrineAccessTokenManager($em); @@ -26,7 +28,7 @@ public function testClearExpired(): void $em->persist($client); $em->flush(); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $testData = $this->buildClearExpiredTestData($client); @@ -72,7 +74,7 @@ private function buildAccessToken(string $identifier, string $modify, Client $cl { return new AccessToken( $identifier, - (new DateTime())->modify($modify), + new DateTimeImmutable($modify), $client, null, [] @@ -81,15 +83,15 @@ private function buildAccessToken(string $identifier, string $modify, Client $cl public function testClearExpiredWithRefreshToken(): void { + /** @var EntityManagerInterface $em */ $em = $this->client->getContainer()->get('doctrine.orm.entity_manager'); - $doctrineAccessTokenManager = new DoctrineAccessTokenManager($em); $client = new Client('client', 'secret'); $em->persist($client); $em->flush(); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $testData = $this->buildClearExpiredTestDataWithRefreshToken($client); @@ -138,10 +140,10 @@ private function buildRefreshToken(string $identifier, string $modify, Client $c { return new RefreshToken( $identifier, - (new DateTime('+1 day')), + new DateTimeImmutable('+1 day'), new AccessToken( $identifier, - (new DateTime())->modify($modify), + new DateTimeImmutable($modify), $client, null, [] diff --git a/Tests/Acceptance/DoctrineAuthCodeManagerTest.php b/Tests/Acceptance/DoctrineAuthCodeManagerTest.php new file mode 100644 index 00000000..bda19680 --- /dev/null +++ b/Tests/Acceptance/DoctrineAuthCodeManagerTest.php @@ -0,0 +1,83 @@ +client->getContainer()->get('doctrine.orm.entity_manager'); + + $doctrineAuthCodeManager = new DoctrineAuthCodeManager($em); + + $client = new Client('client', 'secret'); + $em->persist($client); + + timecop_freeze(new DateTimeImmutable()); + + try { + $testData = $this->buildClearExpiredTestData($client); + + /** @var AuthorizationCode $authCode */ + foreach ($testData['input'] as $authCode) { + $doctrineAuthCodeManager->save($authCode); + } + + $em->flush(); + + $this->assertSame(3, $doctrineAuthCodeManager->clearExpired()); + } finally { + timecop_return(); + } + + $this->assertSame( + $testData['output'], + $em->getRepository(AuthorizationCode::class)->findBy([], ['identifier' => 'ASC']) + ); + } + + private function buildClearExpiredTestData(Client $client): array + { + $validAuthCodes = [ + $this->buildAuthCode('1111', '+1 day', $client), + $this->buildAuthCode('2222', '+1 hour', $client), + $this->buildAuthCode('3333', '+1 second', $client), + $this->buildAuthCode('4444', 'now', $client), + ]; + + $expiredAuthCodes = [ + $this->buildAuthCode('5555', '-1 day', $client), + $this->buildAuthCode('6666', '-1 hour', $client), + $this->buildAuthCode('7777', '-1 second', $client), + ]; + + return [ + 'input' => array_merge($validAuthCodes, $expiredAuthCodes), + 'output' => $validAuthCodes, + ]; + } + + private function buildAuthCode(string $identifier, string $modify, Client $client): AuthorizationCode + { + return new AuthorizationCode( + $identifier, + new DateTimeImmutable($modify), + $client, + null, + [] + ); + } +} diff --git a/Tests/Acceptance/DoctrineClientManagerTest.php b/Tests/Acceptance/DoctrineClientManagerTest.php new file mode 100644 index 00000000..4c1eda4d --- /dev/null +++ b/Tests/Acceptance/DoctrineClientManagerTest.php @@ -0,0 +1,116 @@ +client->getContainer()->get('doctrine.orm.entity_manager'); + $doctrineClientManager = new DoctrineClientManager($em); + + $client = new Client('client', 'secret'); + $em->persist($client); + $em->flush(); + + $doctrineClientManager->remove($client); + + $this->assertNull( + $em + ->getRepository(Client::class) + ->find($client->getIdentifier()) + ); + } + + public function testClientDeleteCascadesToAccessTokens(): void + { + /** @var $em EntityManagerInterface */ + $em = $this->client->getContainer()->get('doctrine.orm.entity_manager'); + $doctrineClientManager = new DoctrineClientManager($em); + + $client = new Client('client', 'secret'); + $em->persist($client); + $em->flush(); + + $accessToken = new AccessToken('access token', new DateTimeImmutable('+1 day'), $client, $client->getIdentifier(), []); + $em->persist($accessToken); + $em->flush(); + + $doctrineClientManager->remove($client); + + $this->assertNull( + $em + ->getRepository(Client::class) + ->find($client->getIdentifier()) + ); + + // The entity manager has to be cleared manually + // because it doesn't process deep integrity constraints + $em->clear(); + + $this->assertNull( + $em + ->getRepository(AccessToken::class) + ->find($accessToken->getIdentifier()) + ); + } + + public function testClientDeleteCascadesToAccessTokensAndRefreshTokens(): void + { + /** @var $em EntityManagerInterface */ + $em = $this->client->getContainer()->get('doctrine.orm.entity_manager'); + $doctrineClientManager = new DoctrineClientManager($em); + + $client = new Client('client', 'secret'); + $em->persist($client); + $em->flush(); + + $accessToken = new AccessToken('access token', new DateTimeImmutable('+1 day'), $client, $client->getIdentifier(), []); + $em->persist($accessToken); + $em->flush(); + + $refreshToken = new RefreshToken('refresh token', new DateTimeImmutable('+1 day'), $accessToken); + $em->persist($refreshToken); + $em->flush(); + + $doctrineClientManager->remove($client); + + $this->assertNull( + $em + ->getRepository(Client::class) + ->find($client->getIdentifier()) + ); + + // The entity manager has to be cleared manually + // because it doesn't process deep integrity constraints + $em->clear(); + + $this->assertNull( + $em + ->getRepository(AccessToken::class) + ->find($accessToken->getIdentifier()) + ); + + /** @var $refreshToken RefreshToken */ + $refreshToken = $em + ->getRepository(RefreshToken::class) + ->find($refreshToken->getIdentifier()) + ; + $this->assertNotNull($refreshToken); + $this->assertNull($refreshToken->getAccessToken()); + } +} diff --git a/Tests/Acceptance/DoctrineRefreshTokenManagerTest.php b/Tests/Acceptance/DoctrineRefreshTokenManagerTest.php index 7213678e..a3359d87 100644 --- a/Tests/Acceptance/DoctrineRefreshTokenManagerTest.php +++ b/Tests/Acceptance/DoctrineRefreshTokenManagerTest.php @@ -2,22 +2,24 @@ declare(strict_types=1); -namespace Trikoder\Bundle\OAuth2Bundle\Tests\Unit; +namespace Trikoder\Bundle\OAuth2Bundle\Tests\Acceptance; -use DateTime; +use DateTimeImmutable; +use Doctrine\ORM\EntityManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine\RefreshTokenManager as DoctrineRefreshTokenManager; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken; use Trikoder\Bundle\OAuth2Bundle\Model\Client; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken; -use Trikoder\Bundle\OAuth2Bundle\Tests\Acceptance\AbstractAcceptanceTest; /** - * @TODO This should be in the Integration tests folder but the current tests infrastructure would need improvements first. + * @TODO This should be in the Integration tests folder but the current tests infrastructure would need improvements first. + * @covers \Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine\RefreshTokenManager */ final class DoctrineRefreshTokenManagerTest extends AbstractAcceptanceTest { public function testClearExpired(): void { + /** @var EntityManagerInterface $em */ $em = $this->client->getContainer()->get('doctrine.orm.entity_manager'); $doctrineRefreshTokenManager = new DoctrineRefreshTokenManager($em); @@ -26,7 +28,7 @@ public function testClearExpired(): void $em->persist($client); $em->flush(); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $testData = $this->buildClearExpiredTestData($client); @@ -75,10 +77,10 @@ private function buildRefreshToken(string $identifier, string $modify, Client $c { return new RefreshToken( $identifier, - (new DateTime())->modify($modify), + new DateTimeImmutable($modify), new AccessToken( $identifier, - (new DateTime('+1 day')), + new DateTimeImmutable('+1 day'), $client, null, [] diff --git a/Tests/Acceptance/TokenEndpointTest.php b/Tests/Acceptance/TokenEndpointTest.php index 127bf4a7..5af4b8b2 100644 --- a/Tests/Acceptance/TokenEndpointTest.php +++ b/Tests/Acceptance/TokenEndpointTest.php @@ -4,7 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Tests\Acceptance; -use DateTime; +use DateTimeImmutable; use Trikoder\Bundle\OAuth2Bundle\Event\UserResolveEvent; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\AuthorizationCodeManagerInterface; @@ -31,7 +31,7 @@ protected function setUp(): void public function testSuccessfulClientCredentialsRequest(): void { - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $this->client->request('POST', '/token', [ @@ -60,11 +60,11 @@ public function testSuccessfulPasswordRequest(): void $this->client ->getContainer() ->get('event_dispatcher') - ->addListener('trikoder.oauth2.user_resolve', function (UserResolveEvent $event): void { + ->addListener('trikoder.oauth2.user_resolve', static function (UserResolveEvent $event): void { $event->setUser(FixtureFactory::createUser()); }); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $this->client->request('POST', '/token', [ @@ -98,7 +98,7 @@ public function testSuccessfulRefreshTokenRequest(): void ->get(RefreshTokenManagerInterface::class) ->find(FixtureFactory::FIXTURE_REFRESH_TOKEN); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $this->client->request('POST', '/token', [ @@ -131,14 +131,46 @@ public function testSuccessfulAuthorizationCodeRequest(): void ->get(AuthorizationCodeManagerInterface::class) ->find(FixtureFactory::FIXTURE_AUTH_CODE); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $this->client->request('POST', '/token', [ - 'client_id' => 'foo', + 'client_id' => FixtureFactory::FIXTURE_CLIENT_FIRST, 'client_secret' => 'secret', 'grant_type' => 'authorization_code', - 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', + 'redirect_uri' => FixtureFactory::FIXTURE_CLIENT_FIRST_REDIRECT_URI, + 'code' => TestHelper::generateEncryptedAuthCodePayload($authCode), + ]); + } finally { + timecop_return(); + } + + $response = $this->client->getResponse(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json; charset=UTF-8', $response->headers->get('Content-Type')); + + $jsonResponse = json_decode($response->getContent(), true); + + $this->assertSame('Bearer', $jsonResponse['token_type']); + $this->assertSame(3600, $jsonResponse['expires_in']); + $this->assertNotEmpty($jsonResponse['access_token']); + } + + public function testSuccessfulAuthorizationCodeRequestWithPublicClient(): void + { + $authCode = $this->client + ->getContainer() + ->get(AuthorizationCodeManagerInterface::class) + ->find(FixtureFactory::FIXTURE_AUTH_CODE_PUBLIC_CLIENT); + + timecop_freeze(new DateTimeImmutable()); + + try { + $this->client->request('POST', '/token', [ + 'client_id' => FixtureFactory::FIXTURE_PUBLIC_CLIENT, + 'grant_type' => 'authorization_code', + 'redirect_uri' => FixtureFactory::FIXTURE_PUBLIC_CLIENT_REDIRECT_URI, 'code' => TestHelper::generateEncryptedAuthCodePayload($authCode), ]); } finally { @@ -161,20 +193,22 @@ public function testSuccessfulAuthorizationCodeOpenIDRequest() { $authCode = $this->client ->getContainer() - ->get(AuthCodeManagerInterface::class) + ->get(AuthorizationCodeManagerInterface::class) ->find(FixtureFactory::FIXTURE_AUTH_CODE_OPENID); - timecop_freeze(new DateTime()); - - $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), - ]); + timecop_freeze(new DateTimeImmutable()); - timecop_return(); + try { + $this->client->request('POST', '/token', [ + 'client_id' => FixtureFactory::FIXTURE_CLIENT_FIRST, + 'client_secret' => 'secret', + 'grant_type' => 'authorization_code', + 'redirect_uri' => FixtureFactory::FIXTURE_CLIENT_FIRST_REDIRECT_URI, + 'code' => TestHelper::generateEncryptedAuthCodePayload($authCode, false), + ]); + } finally { + timecop_return(); + } $response = $this->client->getResponse(); diff --git a/Tests/Acceptance/UserInfoEndpointTest.php b/Tests/Acceptance/UserInfoEndpointTest.php index 340b877e..c41ea5b2 100644 --- a/Tests/Acceptance/UserInfoEndpointTest.php +++ b/Tests/Acceptance/UserInfoEndpointTest.php @@ -3,11 +3,28 @@ namespace Trikoder\Bundle\OAuth2Bundle\Tests\Acceptance; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Manager\AuthorizationCodeManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Tests\Fixtures\FixtureFactory; use Trikoder\Bundle\OAuth2Bundle\Tests\TestHelper; final class UserInfoEndpointTest extends AbstractAcceptanceTest { + protected function setUp(): void + { + parent::setUp(); + + FixtureFactory::initializeFixtures( + $this->client->getContainer()->get(ScopeManagerInterface::class), + $this->client->getContainer()->get(ClientManagerInterface::class), + $this->client->getContainer()->get(AccessTokenManagerInterface::class), + $this->client->getContainer()->get(RefreshTokenManagerInterface::class), + $this->client->getContainer()->get(AuthorizationCodeManagerInterface::class) + ); + } + public function testSuccessfulGetUserInfoRequest() { $accessToken = $this->client->getContainer()->get(AccessTokenManagerInterface::class) diff --git a/Tests/Fixtures/FixtureFactory.php b/Tests/Fixtures/FixtureFactory.php index 23d734cb..34d2acde 100644 --- a/Tests/Fixtures/FixtureFactory.php +++ b/Tests/Fixtures/FixtureFactory.php @@ -4,7 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Tests\Fixtures; -use DateTime; +use DateTimeImmutable; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\AuthorizationCodeManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; @@ -41,6 +41,7 @@ final class FixtureFactory public const FIXTURE_REFRESH_TOKEN_WITH_SCOPES = 'e47d593ed661840b3633e4577c3261ef57ba225be193b190deb69ee9afefdc19f54f890fbdda59f5'; public const FIXTURE_AUTH_CODE = '0aa70e8152259988b3c8e9e8cff604019bb986eb226bd126da189829b95a2be631e2506042064e12'; + public const FIXTURE_AUTH_CODE_PUBLIC_CLIENT = 'xaa70e8152259988b3c8e9e8cff604019bb986eb226bd126da189829b95a2be631e2506042064e12'; public const FIXTURE_AUTH_CODE_DIFFERENT_CLIENT = 'e8fe264053cb346f4437af05c8cc9036931cfec3a0d5b54bdae349304ca4a83fd2f4590afd51e559'; public const FIXTURE_AUTH_CODE_EXPIRED = 'a7bdbeb26c9f095d842f5e5b8e313b24318d6b26728d1c543136727bbe9525f7a7930305a09b7401'; public const FIXTURE_AUTH_CODE_OPENID = '86adfc23d7b07ba70b9a501c03ff9fafb967efb2b4b14099feced03a14430c3c00a69b3282f769a8'; @@ -50,8 +51,12 @@ final class FixtureFactory public const FIXTURE_CLIENT_INACTIVE = 'baz_inactive'; public const FIXTURE_CLIENT_RESTRICTED_GRANTS = 'qux_restricted_grants'; public const FIXTURE_CLIENT_RESTRICTED_SCOPES = 'quux_restricted_scopes'; + public const FIXTURE_PUBLIC_CLIENT = 'foo_public'; + public const FIXTURE_PUBLIC_CLIENT_ALLOWED_TO_USE_PLAIN_CHALLENGE_METHOD = 'bar_public'; public const FIXTURE_CLIENT_FIRST_REDIRECT_URI = 'https://example.org/oauth2/redirect-uri'; + public const FIXTURE_PUBLIC_CLIENT_REDIRECT_URI = 'https://example.org/oauth2/redirect-uri-foo-test'; + public const FIXTURE_PUBLIC_CLIENT_ALLOWED_TO_USE_PLAIN_CHALLENGE_METHOD_REDIRECT_URI = 'https://example.org/oauth2/redirect-uri-bar-test'; public const FIXTURE_SCOPE_FIRST = 'fancy'; public const FIXTURE_SCOPE_SECOND = 'rock'; @@ -105,7 +110,7 @@ private static function createAccessTokens(ScopeManagerInterface $scopeManager, $accessTokens[] = (new AccessToken( self::FIXTURE_ACCESS_TOKEN_USER_BOUND, - new DateTime('+1 hour'), + new DateTimeImmutable('+1 hour'), $clientManager->find(self::FIXTURE_CLIENT_FIRST), self::FIXTURE_USER, [] @@ -113,7 +118,7 @@ private static function createAccessTokens(ScopeManagerInterface $scopeManager, $accessTokens[] = (new AccessToken( self::FIXTURE_ACCESS_TOKEN_DIFFERENT_CLIENT, - new DateTime('+1 hour'), + new DateTimeImmutable('+1 hour'), $clientManager->find(self::FIXTURE_CLIENT_SECOND), self::FIXTURE_USER, [] @@ -121,7 +126,7 @@ private static function createAccessTokens(ScopeManagerInterface $scopeManager, $accessTokens[] = (new AccessToken( self::FIXTURE_ACCESS_TOKEN_EXPIRED, - new DateTime('-1 hour'), + new DateTimeImmutable('-1 hour'), $clientManager->find(self::FIXTURE_CLIENT_FIRST), self::FIXTURE_USER, [] @@ -129,7 +134,7 @@ private static function createAccessTokens(ScopeManagerInterface $scopeManager, $accessTokens[] = (new AccessToken( self::FIXTURE_ACCESS_TOKEN_REVOKED, - new DateTime('+1 hour'), + new DateTimeImmutable('+1 hour'), $clientManager->find(self::FIXTURE_CLIENT_FIRST), self::FIXTURE_USER, [] @@ -138,7 +143,7 @@ private static function createAccessTokens(ScopeManagerInterface $scopeManager, $accessTokens[] = new AccessToken( self::FIXTURE_ACCESS_TOKEN_PUBLIC, - new DateTime('+1 hour'), + new DateTimeImmutable('+1 hour'), $clientManager->find(self::FIXTURE_CLIENT_FIRST), null, [] @@ -146,7 +151,7 @@ private static function createAccessTokens(ScopeManagerInterface $scopeManager, $accessTokens[] = (new AccessToken( self::FIXTURE_ACCESS_TOKEN_WITH_SCOPES, - new DateTime('+1 hour'), + new DateTimeImmutable('+1 hour'), $clientManager->find(self::FIXTURE_CLIENT_FIRST), null, [$scopeManager->find(self::FIXTURE_SCOPE_FIRST)] @@ -154,7 +159,7 @@ private static function createAccessTokens(ScopeManagerInterface $scopeManager, $accessTokens[] = (new AccessToken( self::FIXTURE_ACCESS_TOKEN_USER_BOUND_WITH_SCOPES, - new DateTime('+1 hour'), + new DateTimeImmutable('+1 hour'), $clientManager->find(self::FIXTURE_CLIENT_FIRST), self::FIXTURE_USER, [$scopeManager->find(self::FIXTURE_SCOPE_FIRST)] @@ -172,32 +177,32 @@ private static function createRefreshTokens(AccessTokenManagerInterface $accessT $refreshTokens[] = new RefreshToken( self::FIXTURE_REFRESH_TOKEN, - new DateTime('+1 month'), + new DateTimeImmutable('+1 month'), $accessTokenManager->find(self::FIXTURE_ACCESS_TOKEN_USER_BOUND) ); $refreshTokens[] = new RefreshToken( self::FIXTURE_REFRESH_TOKEN_DIFFERENT_CLIENT, - new DateTime('+1 month'), + new DateTimeImmutable('+1 month'), $accessTokenManager->find(self::FIXTURE_ACCESS_TOKEN_DIFFERENT_CLIENT) ); $refreshTokens[] = new RefreshToken( self::FIXTURE_REFRESH_TOKEN_EXPIRED, - new DateTime('-1 month'), + new DateTimeImmutable('-1 month'), $accessTokenManager->find(self::FIXTURE_ACCESS_TOKEN_EXPIRED) ); $refreshTokens[] = (new RefreshToken( self::FIXTURE_REFRESH_TOKEN_REVOKED, - new DateTime('+1 month'), + new DateTimeImmutable('+1 month'), $accessTokenManager->find(self::FIXTURE_ACCESS_TOKEN_REVOKED) )) ->revoke(); $refreshTokens[] = new RefreshToken( self::FIXTURE_REFRESH_TOKEN_WITH_SCOPES, - new DateTime('+1 month'), + new DateTimeImmutable('+1 month'), $accessTokenManager->find(self::FIXTURE_ACCESS_TOKEN_USER_BOUND_WITH_SCOPES) ); @@ -213,15 +218,23 @@ public static function createAuthorizationCodes(ClientManagerInterface $clientMa $authorizationCodes[] = new AuthorizationCode( self::FIXTURE_AUTH_CODE, - new DateTime('+2 minute'), + new DateTimeImmutable('+2 minute'), $clientManager->find(self::FIXTURE_CLIENT_FIRST), self::FIXTURE_USER, [] ); + $authorizationCodes[] = new AuthorizationCode( + self::FIXTURE_AUTH_CODE_PUBLIC_CLIENT, + new DateTimeImmutable('+2 minute'), + $clientManager->find(self::FIXTURE_PUBLIC_CLIENT), + self::FIXTURE_USER, + [] + ); + $authorizationCodes[] = new AuthorizationCode( self::FIXTURE_AUTH_CODE_DIFFERENT_CLIENT, - new DateTime('+2 minute'), + new DateTimeImmutable('+2 minute'), $clientManager->find(self::FIXTURE_CLIENT_SECOND), self::FIXTURE_USER, [] @@ -229,15 +242,15 @@ public static function createAuthorizationCodes(ClientManagerInterface $clientMa $authorizationCodes[] = new AuthorizationCode( self::FIXTURE_AUTH_CODE_EXPIRED, - new DateTime('-30 minute'), + new DateTimeImmutable('-30 minute'), $clientManager->find(self::FIXTURE_CLIENT_FIRST), self::FIXTURE_USER, [] ); - $authCodes[] = new AuthCode( + $authorizationCodes[] = new AuthorizationCode( self::FIXTURE_AUTH_CODE_OPENID, - new DateTime('+2 minute'), + new DateTimeImmutable('+2 minute'), $clientManager->find(self::FIXTURE_CLIENT_FIRST), self::FIXTURE_USER, [new Scope(self::FIXTURE_SCOPE_OPENID)] @@ -268,6 +281,13 @@ private static function createClients(): array $clients[] = (new Client(self::FIXTURE_CLIENT_RESTRICTED_SCOPES, 'beer')) ->setScopes(new Scope(self::FIXTURE_SCOPE_SECOND)); + $clients[] = (new Client(self::FIXTURE_PUBLIC_CLIENT, null)) + ->setRedirectUris(new RedirectUri(self::FIXTURE_PUBLIC_CLIENT_REDIRECT_URI)); + + $clients[] = (new Client(self::FIXTURE_PUBLIC_CLIENT_ALLOWED_TO_USE_PLAIN_CHALLENGE_METHOD, null)) + ->setAllowPlainTextPkce(true) + ->setRedirectUris(new RedirectUri(self::FIXTURE_PUBLIC_CLIENT_ALLOWED_TO_USE_PLAIN_CHALLENGE_METHOD_REDIRECT_URI)); + return $clients; } diff --git a/Tests/Fixtures/SecurityTestController.php b/Tests/Fixtures/SecurityTestController.php index 4a533598..2384bbdc 100644 --- a/Tests/Fixtures/SecurityTestController.php +++ b/Tests/Fixtures/SecurityTestController.php @@ -6,11 +6,21 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Core\Role\Role; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\User\UserInterface; final class SecurityTestController extends AbstractController { + /** + * @var TokenStorageInterface + */ + private $tokenStorage; + + public function __construct(TokenStorageInterface $tokenStorage) + { + $this->tokenStorage = $tokenStorage; + } + public function helloAction(): Response { /** @var UserInterface $user */ @@ -28,11 +38,7 @@ public function scopeAction(): Response public function rolesAction(): Response { - $roles = $this->get('security.token_storage')->getToken()->getRoles(); - - $roles = array_map(function (Role $role): string { - return $role->getRole(); - }, $roles); + $roles = $this->tokenStorage->getToken()->getRoleNames(); return new Response( sprintf( diff --git a/Tests/Integration/AbstractIntegrationTest.php b/Tests/Integration/AbstractIntegrationTest.php index 5114c1fd..0b295daf 100644 --- a/Tests/Integration/AbstractIntegrationTest.php +++ b/Tests/Integration/AbstractIntegrationTest.php @@ -28,11 +28,11 @@ use OpenIDConnectServer\Repositories\IdentityProviderInterface; use League\OAuth2\Server\ResourceServer; use Nyholm\Psr7\Factory\Psr17Factory; +use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Symfony\Bundle\FrameworkBundle\Tests\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Trikoder\Bundle\OAuth2Bundle\Converter\ScopeConverter; use Trikoder\Bundle\OAuth2Bundle\Converter\UserConverter; use Trikoder\Bundle\OAuth2Bundle\League\Entity\User; @@ -56,7 +56,6 @@ use Trikoder\Bundle\OAuth2Bundle\Tests\Fixtures\FixtureFactory; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken; -use Trikoder\Bundle\OAuth2Bundle\Service\BCEventDispatcher; use Trikoder\Bundle\OAuth2Bundle\Tests\TestHelper; abstract class AbstractIntegrationTest extends TestCase @@ -106,6 +105,11 @@ abstract class AbstractIntegrationTest extends TestCase */ private $psrFactory; + /** + * @var bool + */ + private $requireCodeChallengeForPublicClients = true; + /** * {@inheritdoc} */ @@ -116,7 +120,7 @@ protected function setUp(): void $this->accessTokenManager = new AccessTokenManager(); $this->refreshTokenManager = new RefreshTokenManager(); $this->authCodeManager = new AuthorizationCodeManager(); - $this->eventDispatcher = new BCEventDispatcher(new EventDispatcher()); + $this->eventDispatcher = new EventDispatcher(); $scopeConverter = new ScopeConverter(); $scopeRepository = new ScopeRepository($this->scopeManager, $this->clientManager, $scopeConverter, $this->eventDispatcher); @@ -264,6 +268,16 @@ protected function extractQueryDataFromUri(string $uri): array return $data; } + protected function enableRequireCodeChallengeForPublicClients(): void + { + $this->requireCodeChallengeForPublicClients = true; + } + + protected function disableRequireCodeChallengeForPublicClients(): void + { + $this->requireCodeChallengeForPublicClients = false; + } + private function createAuthorizationServer( ScopeRepositoryInterface $scopeRepository, ClientRepositoryInterface $clientRepository, @@ -282,10 +296,16 @@ private function createAuthorizationServer( new IdTokenResponse($identityRepository, new ClaimExtractor()) ); + $authCodeGrant = new AuthCodeGrant($authCodeRepository, $refreshTokenRepository, new DateInterval('PT10M')); + + if (!$this->requireCodeChallengeForPublicClients) { + $authCodeGrant->disableRequireCodeChallengeForPublicClients(); + } + $authorizationServer->enableGrantType(new ClientCredentialsGrant()); $authorizationServer->enableGrantType(new RefreshTokenGrant($refreshTokenRepository)); $authorizationServer->enableGrantType(new PasswordGrant($userRepository, $refreshTokenRepository)); - $authorizationServer->enableGrantType(new AuthCodeGrant($authCodeRepository, $refreshTokenRepository, new DateInterval('PT10M'))); + $authorizationServer->enableGrantType($authCodeGrant); $authorizationServer->enableGrantType(new ImplicitGrant(new DateInterval('PT10M'))); return $authorizationServer; diff --git a/Tests/Integration/AuthCodeRepositoryTest.php b/Tests/Integration/AuthCodeRepositoryTest.php new file mode 100644 index 00000000..bd090d3c --- /dev/null +++ b/Tests/Integration/AuthCodeRepositoryTest.php @@ -0,0 +1,38 @@ +authCodeManager->save($authCode); + + $this->assertSame($authCode, $this->authCodeManager->find($identifier)); + + $authCodeRepository = new AuthCodeRepository($this->authCodeManager, $this->clientManager, new ScopeConverter()); + + $authCodeRepository->revokeAuthCode($identifier); + + $this->assertTrue($authCode->isRevoked()); + $this->assertSame($authCode, $this->authCodeManager->find($identifier)); + } +} diff --git a/Tests/Integration/AuthorizationServerTest.php b/Tests/Integration/AuthorizationServerTest.php index 03cc4ad8..cc1e9f40 100644 --- a/Tests/Integration/AuthorizationServerTest.php +++ b/Tests/Integration/AuthorizationServerTest.php @@ -4,7 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Tests\Integration; -use DateTime; +use DateTimeImmutable; use Trikoder\Bundle\OAuth2Bundle\Event\UserResolveEvent; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken; @@ -21,7 +21,8 @@ protected function setUp(): void $this->scopeManager, $this->clientManager, $this->accessTokenManager, - $this->refreshTokenManager + $this->refreshTokenManager, + $this->authCodeManager ); } @@ -167,7 +168,7 @@ public function testValidClientCredentialsGrant(): void 'grant_type' => 'client_credentials', ]); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $response = $this->handleTokenRequest($request); @@ -193,7 +194,7 @@ public function testValidClientCredentialsGrantWithScope(): void 'scope' => 'fancy', ]); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $response = $this->handleTokenRequest($request); @@ -226,7 +227,7 @@ public function testValidClientCredentialsGrantWithInheritedScope(): void 'grant_type' => 'client_credentials', ]); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $response = $this->handleTokenRequest($request); @@ -260,7 +261,7 @@ public function testValidClientCredentialsGrantWithRequestedScope(): void 'scope' => 'rock', ]); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $response = $this->handleTokenRequest($request); @@ -289,7 +290,7 @@ public function testValidClientCredentialsGrantWithRequestedScope(): void public function testValidPasswordGrant(): void { - $this->eventDispatcher->addListener('trikoder.oauth2.user_resolve', function (UserResolveEvent $event): void { + $this->eventDispatcher->addListener('trikoder.oauth2.user_resolve', static function (UserResolveEvent $event): void { $event->setUser(FixtureFactory::createUser()); }); @@ -299,7 +300,7 @@ public function testValidPasswordGrant(): void 'password' => 'pass', ]); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $response = $this->handleTokenRequest($request); @@ -325,7 +326,7 @@ public function testValidPasswordGrant(): void public function testInvalidCredentialsPasswordGrant(): void { - $this->eventDispatcher->addListener('trikoder.oauth2.user_resolve', function (UserResolveEvent $event): void { + $this->eventDispatcher->addListener('trikoder.oauth2.user_resolve', static function (UserResolveEvent $event): void { $event->setUser(null); }); @@ -338,8 +339,8 @@ public function testInvalidCredentialsPasswordGrant(): void $response = $this->handleTokenRequest($request); // Response assertions. - $this->assertSame('invalid_credentials', $response['error']); - $this->assertSame('The user credentials were incorrect.', $response['message']); + $this->assertSame('invalid_grant', $response['error']); + $this->assertSame('The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.', $response['message']); } public function testMissingUsernameFieldPasswordGrant(): void @@ -382,7 +383,7 @@ public function testValidRefreshGrant(): void 'refresh_token' => TestHelper::generateEncryptedPayload($existingRefreshToken), ]); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $response = $this->handleTokenRequest($request); @@ -674,7 +675,7 @@ public function testSuccessfulAuthorizationWithCode(): void 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', ]); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $response = $this->handleTokenRequest($request); @@ -750,7 +751,7 @@ public function testSuccessfulImplicitRequest(): void 'client_id' => 'foo', ]); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $response = $this->handleAuthorizationRequest($request); @@ -778,7 +779,7 @@ public function testSuccessfulImplicitRequestWithState(): void 'state' => 'quzbaz', ]); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $response = $this->handleAuthorizationRequest($request); @@ -807,7 +808,7 @@ public function testSuccessfulImplicitRequestRedirectUri(): void 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', ]); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $response = $this->handleAuthorizationRequest($request); diff --git a/Tests/Integration/OpenIDProviderTest.php b/Tests/Integration/OpenIDProviderTest.php index 5f19941b..0d43758d 100644 --- a/Tests/Integration/OpenIDProviderTest.php +++ b/Tests/Integration/OpenIDProviderTest.php @@ -8,13 +8,26 @@ final class OpenIDProviderTest extends AbstractIntegrationTest { + protected function setUp(): void + { + parent::setUp(); + + FixtureFactory::initializeFixtures( + $this->scopeManager, + $this->clientManager, + $this->accessTokenManager, + $this->refreshTokenManager, + $this->authCodeManager + ); + } + public function testSuccessfulIDTokenRequest(): void { $openIdAuthCode = $this->authCodeManager->find(FixtureFactory::FIXTURE_AUTH_CODE_OPENID); $request = $this->createAuthorizationRequest('foo:secret', [ 'grant_type' => 'authorization_code', - 'code' => TestHelper::generateEncryptedAuthCodePayload($openIdAuthCode), + 'code' => TestHelper::generateEncryptedAuthCodePayload($openIdAuthCode, false), 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', ]); diff --git a/Tests/Integration/ResourceServerTest.php b/Tests/Integration/ResourceServerTest.php index ec866617..2ad1977f 100644 --- a/Tests/Integration/ResourceServerTest.php +++ b/Tests/Integration/ResourceServerTest.php @@ -17,7 +17,8 @@ protected function setUp(): void $this->scopeManager, $this->clientManager, $this->accessTokenManager, - $this->refreshTokenManager + $this->refreshTokenManager, + $this->authCodeManager ); } diff --git a/Tests/Support/SqlitePlatform.php b/Tests/Support/SqlitePlatform.php new file mode 100644 index 00000000..37372108 --- /dev/null +++ b/Tests/Support/SqlitePlatform.php @@ -0,0 +1,38 @@ + $authCode->getClient()->getIdentifier(), 'redirect_uri' => (string) $authCode->getClient()->getRedirectUris()[0], 'auth_code_id' => $authCode->getIdentifier(), - 'scopes' => (new ScopeConverter())->toDomainArray($authCode->getScopes()), + 'scopes' => $convertScopes ? (new ScopeConverter())->toDomainArray($authCode->getScopes()) : $authCode->getScopes(), 'user_id' => $authCode->getUserIdentifier(), 'expire_time' => $authCode->getExpiryDateTime()->getTimestamp(), 'code_challenge' => null, @@ -62,6 +63,15 @@ public static function generateEncryptedAuthCodePayload(AuthorizationCodeModel $ } } + public static function decryptPayload(string $payload): ?string + { + try { + return Crypto::decryptWithPassword($payload, self::ENCRYPTION_KEY); + } catch (CryptoException $e) { + return null; + } + } + public static function generateJwtToken(AccessTokenModel $accessToken): string { $clientEntity = new ClientEntity(); @@ -69,6 +79,7 @@ public static function generateJwtToken(AccessTokenModel $accessToken): string $clientEntity->setRedirectUri(array_map('strval', $accessToken->getClient()->getRedirectUris())); $accessTokenEntity = new AccessTokenEntity(); + $accessTokenEntity->setPrivateKey(new CryptKey(self::PRIVATE_KEY_PATH, null, false)); $accessTokenEntity->setIdentifier($accessToken->getIdentifier()); $accessTokenEntity->setExpiryDateTime($accessToken->getExpiry()); $accessTokenEntity->setClient($clientEntity); @@ -81,11 +92,12 @@ public static function generateJwtToken(AccessTokenModel $accessToken): string $accessTokenEntity->addScope($scopeEntity); } - return (string) $accessTokenEntity->convertToJWT( - new CryptKey(self::PRIVATE_KEY_PATH, null, false) - ); + return (string) $accessTokenEntity; } + /** + * @throws Exception + */ public static function initializeDoctrineSchema(Application $application, array $arguments = []): bool { $statusCode = $application diff --git a/Tests/TestKernel.php b/Tests/TestKernel.php index 102d8405..2dbf713e 100644 --- a/Tests/TestKernel.php +++ b/Tests/TestKernel.php @@ -4,6 +4,10 @@ namespace Trikoder\Bundle\OAuth2Bundle\Tests; +use Laminas\Diactoros\ResponseFactory; +use Laminas\Diactoros\ServerRequestFactory; +use Laminas\Diactoros\StreamFactory; +use Laminas\Diactoros\UploadedFileFactory; use LogicException; use Nyholm\Psr7\Factory as Nyholm; use Psr\Http\Message\ResponseFactoryInterface; @@ -11,6 +15,7 @@ use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\UploadedFileFactoryInterface; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Component\Config\Exception\LoaderLoadException; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -22,9 +27,10 @@ use Trikoder\Bundle\OAuth2Bundle\Manager\ClientManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationDecision\AlwaysAllowDecisionStrategy; use Trikoder\Bundle\OAuth2Bundle\Tests\Fixtures\FixtureFactory; use Trikoder\Bundle\OAuth2Bundle\Tests\Fixtures\SecurityTestController; -use Zend\Diactoros as ZendFramework; +use Trikoder\Bundle\OAuth2Bundle\Tests\Support\SqlitePlatform; final class TestKernel extends Kernel implements CompilerPassInterface { @@ -97,6 +103,8 @@ protected function getContainerClass() /** * {@inheritdoc} + * + * @throws LoaderLoadException */ protected function configureRoutes(RouteCollectionBuilder $routes) { @@ -131,6 +139,7 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa 'charset' => 'utf8mb4', 'utf8mb4_unicode_ci' => 'utf8mb4_unicode_ci', ], + 'platform_service' => SqlitePlatform::class, ], 'orm' => null, ]); @@ -138,6 +147,9 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa $container->loadFromExtension('framework', [ 'secret' => 'nope', 'test' => null, + 'session' => [ + 'storage_id' => 'session.storage.mock_file' + ] ]); $container->loadFromExtension('security', [ @@ -171,6 +183,8 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa 'authorization_server' => [ 'private_key' => '%env(PRIVATE_KEY_PATH)%', 'encryption_key' => '%env(ENCRYPTION_KEY)%', + 'authorization_strategy' => AlwaysAllowDecisionStrategy::class, + 'consent_route' => 'default' ], 'resource_server' => [ 'public_key' => '%env(PUBLIC_KEY_PATH)%', @@ -184,19 +198,21 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa ], ], 'openid_connect' => [ - 'enabled' => true, + 'enabled' => false, + 'login_route' => 'default' ], ]); $this->configureControllers($container); $this->configurePsrHttpFactory($container); + $this->configureDatabaseServices($container); } private function exposeManagerServices(ContainerBuilder $container): void { $container ->getDefinition( - $container + (string) $container ->getAlias(ScopeManagerInterface::class) ->setPublic(true) ) @@ -205,7 +221,7 @@ private function exposeManagerServices(ContainerBuilder $container): void $container ->getDefinition( - $container + (string) $container ->getAlias(ClientManagerInterface::class) ->setPublic(true) ) @@ -214,7 +230,7 @@ private function exposeManagerServices(ContainerBuilder $container): void $container ->getDefinition( - $container + (string) $container ->getAlias(AccessTokenManagerInterface::class) ->setPublic(true) ) @@ -223,7 +239,7 @@ private function exposeManagerServices(ContainerBuilder $container): void $container ->getDefinition( - $container + (string) $container ->getAlias(RefreshTokenManagerInterface::class) ->setPublic(true) ) @@ -232,7 +248,7 @@ private function exposeManagerServices(ContainerBuilder $container): void $container ->getDefinition( - $container + (string) $container ->getAlias(AuthorizationCodeManagerInterface::class) ->setPublic(true) ) @@ -244,10 +260,10 @@ private function configurePsrHttpFactory(ContainerBuilder $container): void { switch ($this->psrHttpProvider) { case self::PSR_HTTP_PROVIDER_ZENDFRAMEWORK: - $serverRequestFactory = ZendFramework\ServerRequestFactory::class; - $streamFactory = ZendFramework\StreamFactory::class; - $uploadedFileFactory = ZendFramework\UploadedFileFactory::class; - $responseFactory = ZendFramework\ResponseFactory::class; + $serverRequestFactory = ServerRequestFactory::class; + $streamFactory = StreamFactory::class; + $uploadedFileFactory = UploadedFileFactory::class; + $responseFactory = ResponseFactory::class; break; case self::PSR_HTTP_PROVIDER_NYHOLM: $serverRequestFactory = Nyholm\Psr17Factory::class; @@ -256,9 +272,7 @@ private function configurePsrHttpFactory(ContainerBuilder $container): void $responseFactory = Nyholm\Psr17Factory::class; break; default: - throw new LogicException( - sprintf('PSR HTTP factory provider \'%s\' is not supported.', $this->psrHttpProvider) - ); + throw new LogicException(sprintf('PSR HTTP factory provider \'%s\' is not supported.', $this->psrHttpProvider)); } $container->addDefinitions([ @@ -285,6 +299,15 @@ private function configureControllers(ContainerBuilder $container): void ; } + private function configureDatabaseServices(ContainerBuilder $container): void + { + $container + ->register(SqlitePlatform::class) + ->setAutoconfigured(true) + ->setAutowired(true) + ; + } + private function determinePsrHttpFactory(): void { $psrHttpProvider = getenv('PSR_HTTP_PROVIDER'); @@ -297,9 +320,7 @@ private function determinePsrHttpFactory(): void $this->psrHttpProvider = self::PSR_HTTP_PROVIDER_NYHOLM; break; default: - throw new LogicException( - sprintf('PSR HTTP factory provider \'%s\' is not supported.', $psrHttpProvider) - ); + throw new LogicException(sprintf('PSR HTTP factory provider \'%s\' is not supported.', $psrHttpProvider)); } } diff --git a/Tests/Unit/ClientEntityTest.php b/Tests/Unit/ClientEntityTest.php new file mode 100644 index 00000000..aced381c --- /dev/null +++ b/Tests/Unit/ClientEntityTest.php @@ -0,0 +1,30 @@ +assertSame($isConfidential, $client->isConfidential()); + } + + public function confidentialDataProvider(): iterable + { + return [ + 'Client with null secret is not confidential' => [null, false], + 'Client with empty secret is not confidential' => ['', false], + 'Client with non empty secret is confidential' => ['f', true], + ]; + } +} diff --git a/Tests/Unit/ExtensionTest.php b/Tests/Unit/ExtensionTest.php index e2e4c7d7..bba4b082 100644 --- a/Tests/Unit/ExtensionTest.php +++ b/Tests/Unit/ExtensionTest.php @@ -5,6 +5,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Tests\Unit; use League\OAuth2\Server\AuthorizationServer; +use League\OAuth2\Server\Grant\AuthCodeGrant; use League\OAuth2\Server\Grant\ClientCredentialsGrant; use League\OAuth2\Server\Grant\PasswordGrant; use League\OAuth2\Server\Grant\RefreshTokenGrant; @@ -12,7 +13,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Trikoder\Bundle\OAuth2Bundle\DependencyInjection\TrikoderOAuth2Extension; use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\ScopeManager; -use Trikoder\Bundle\OAuth2Bundle\Manager\ScopeManagerInterface; +use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationDecision\AlwaysAllowDecisionStrategy; final class ExtensionTest extends TestCase { @@ -65,7 +66,53 @@ public function grantsProvider(): iterable ]; } - private function getValidConfiguration(array $options): array + /** + * @dataProvider requireCodeChallengeForPublicClientsProvider + */ + public function testAuthCodeGrantDisableRequireCodeChallengeForPublicClientsConfig( + ?bool $requireCodeChallengeForPublicClients, + bool $shouldTheRequirementBeDisabled + ): void { + $container = new ContainerBuilder(); + + $this->setupContainer($container); + + $extension = new TrikoderOAuth2Extension(); + + $configuration = $this->getValidConfiguration(); + $configuration[0]['authorization_server']['require_code_challenge_for_public_clients'] = $requireCodeChallengeForPublicClients; + + $extension->load($configuration, $container); + + $authorizationServer = $container->getDefinition(AuthCodeGrant::class); + $methodCalls = $authorizationServer->getMethodCalls(); + + $isRequireCodeChallengeForPublicClientsDisabled = false; + + foreach ($methodCalls as $methodCall) { + if ('disableRequireCodeChallengeForPublicClients' === $methodCall[0]) { + $isRequireCodeChallengeForPublicClientsDisabled = true; + break; + } + } + + $this->assertSame($shouldTheRequirementBeDisabled, $isRequireCodeChallengeForPublicClientsDisabled); + } + + public function requireCodeChallengeForPublicClientsProvider(): iterable + { + yield 'when not requiring code challenge for public clients the requirement should be disabled' => [ + false, true, + ]; + yield 'when code challenge for public clients is required the requirement should not be disabled' => [ + true, false, + ]; + yield 'with the default value the requirement should not be disabled' => [ + null, false, + ]; + } + + private function getValidConfiguration(array $options = []): array { return [ [ @@ -75,11 +122,15 @@ private function getValidConfiguration(array $options): array 'enable_client_credentials_grant' => $options['enable_client_credentials_grant'] ?? true, 'enable_password_grant' => $options['enable_password_grant'] ?? true, 'enable_refresh_token_grant' => $options['enable_refresh_token_grant'] ?? true, + 'authorization_strategy' => AlwaysAllowDecisionStrategy::class, + 'consent_route' => 'oauth2_consent' ], 'resource_server' => [ 'public_key' => 'foo', ], - 'persistence' => [], + //Pick one for valid config: + //'persistence' => ['doctrine' => []] + 'persistence' => ['in_memory' => 1], ], ]; } @@ -87,6 +138,5 @@ private function getValidConfiguration(array $options): array private function setupContainer(ContainerBuilder $container): void { $container->register(ScopeManager::class); - $container->setAlias(ScopeManagerInterface::class, ScopeManager::class); } } diff --git a/Tests/Unit/InMemoryAccessTokenManagerTest.php b/Tests/Unit/InMemoryAccessTokenManagerTest.php index 542e031e..c7986538 100644 --- a/Tests/Unit/InMemoryAccessTokenManagerTest.php +++ b/Tests/Unit/InMemoryAccessTokenManagerTest.php @@ -4,7 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Tests\Unit; -use DateTime; +use DateTimeImmutable; use PHPUnit\Framework\TestCase; use ReflectionProperty; use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\AccessTokenManager as InMemoryAccessTokenManager; @@ -17,7 +17,7 @@ public function testClearExpired(): void { $inMemoryAccessTokenManager = new InMemoryAccessTokenManager(); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $testData = $this->buildClearExpiredTestData(); @@ -62,7 +62,7 @@ private function buildAccessToken(string $identifier, string $modify): AccessTok { return new AccessToken( $identifier, - (new DateTime())->modify($modify), + new DateTimeImmutable($modify), new Client('client', 'secret'), null, [] diff --git a/Tests/Unit/InMemoryAuthCodeManagerTest.php b/Tests/Unit/InMemoryAuthCodeManagerTest.php new file mode 100644 index 00000000..df7ad6ba --- /dev/null +++ b/Tests/Unit/InMemoryAuthCodeManagerTest.php @@ -0,0 +1,72 @@ +buildClearExpiredTestData(); + + /** @var AuthorizationCode $authCode */ + foreach ($testData['input'] as $authCode) { + $inMemoryAuthCodeManager->save($authCode); + } + + $this->assertSame(3, $inMemoryAuthCodeManager->clearExpired()); + } finally { + timecop_return(); + } + + $reflectionProperty = new ReflectionProperty(InMemoryAuthCodeManager::class, 'authorizationCodes'); + $reflectionProperty->setAccessible(true); + + $this->assertSame($testData['output'], $reflectionProperty->getValue($inMemoryAuthCodeManager)); + } + + private function buildClearExpiredTestData(): array + { + $validAuthCodes = [ + '1111' => $this->buildAuthCode('1111', '+1 day'), + '2222' => $this->buildAuthCode('2222', '+1 hour'), + '3333' => $this->buildAuthCode('3333', '+1 second'), + '4444' => $this->buildAuthCode('4444', 'now'), + ]; + + $expiredAuthCodes = [ + '5555' => $this->buildAuthCode('5555', '-1 day'), + '6666' => $this->buildAuthCode('6666', '-1 hour'), + '7777' => $this->buildAuthCode('7777', '-1 second'), + ]; + + return [ + 'input' => $validAuthCodes + $expiredAuthCodes, + 'output' => $validAuthCodes, + ]; + } + + private function buildAuthCode(string $identifier, string $modify): AuthorizationCode + { + return new AuthorizationCode( + $identifier, + new DateTimeImmutable($modify), + new Client('client', 'secret'), + null, + [] + ); + } +} diff --git a/Tests/Unit/InMemoryRefreshTokenManagerTest.php b/Tests/Unit/InMemoryRefreshTokenManagerTest.php index 73a3282d..62e958d9 100644 --- a/Tests/Unit/InMemoryRefreshTokenManagerTest.php +++ b/Tests/Unit/InMemoryRefreshTokenManagerTest.php @@ -4,7 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Tests\Unit; -use DateTime; +use DateTimeImmutable; use PHPUnit\Framework\TestCase; use ReflectionProperty; use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\RefreshTokenManager as InMemoryRefreshTokenManager; @@ -18,7 +18,7 @@ public function testClearExpired(): void { $inMemoryRefreshTokenManager = new InMemoryRefreshTokenManager(); - timecop_freeze(new DateTime()); + timecop_freeze(new DateTimeImmutable()); try { $testData = $this->buildClearExpiredTestData(); @@ -63,10 +63,10 @@ private function buildRefreshToken(string $identifier, string $modify): RefreshT { return new RefreshToken( $identifier, - (new DateTime())->modify($modify), + new DateTimeImmutable($modify), new AccessToken( $identifier, - (new DateTime('+1 day')), + new DateTimeImmutable('+1 day'), new Client('client', 'secret'), null, [] diff --git a/Tests/Unit/OAuth2ProviderTest.php b/Tests/Unit/OAuth2ProviderTest.php new file mode 100644 index 00000000..f1227f2d --- /dev/null +++ b/Tests/Unit/OAuth2ProviderTest.php @@ -0,0 +1,49 @@ +createMock(UserProviderInterface::class), + $this->createMock(ResourceServer::class), + $tokenFactory, + $providerKey + ); + + $this->assertTrue($provider->supports($this->createToken($tokenFactory, $providerKey))); + $this->assertFalse($provider->supports($this->createToken($tokenFactory, $providerKey . 'bar'))); + } + + private function createToken(OAuth2TokenFactory $tokenFactory, string $providerKey): OAuth2Token + { + $scopes = [FixtureFactory::FIXTURE_SCOPE_FIRST]; + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest->expects($this->once()) + ->method('getAttribute') + ->with('oauth_scopes', []) + ->willReturn($scopes); + + $user = new User(); + + return $tokenFactory->createOAuth2Token($serverRequest, $user, $providerKey); + } +} diff --git a/Tests/Unit/OAuth2TokenFactoryTest.php b/Tests/Unit/OAuth2TokenFactoryTest.php new file mode 100644 index 00000000..3a29c559 --- /dev/null +++ b/Tests/Unit/OAuth2TokenFactoryTest.php @@ -0,0 +1,43 @@ +createMock(ServerRequestInterface::class); + $serverRequest->expects($this->once()) + ->method('getAttribute') + ->with('oauth_scopes', []) + ->willReturn($scopes); + + $user = new User(); + $providerKey = 'main'; + + $token = $factory->createOAuth2Token($serverRequest, $user, $providerKey); + + $this->assertInstanceOf(OAuth2Token::class, $token); + + $roles = $token->getRoleNames(); + $this->assertCount(1, $roles); + $this->assertSame($rolePrefix . strtoupper($scopes[0]), $roles[0]); + + $this->assertFalse($token->isAuthenticated()); + $this->assertSame($user, $token->getUser()); + $this->assertSame($providerKey, $token->getProviderKey()); + } +} diff --git a/Tests/Unit/OAuth2TokenTest.php b/Tests/Unit/OAuth2TokenTest.php new file mode 100644 index 00000000..81b0255b --- /dev/null +++ b/Tests/Unit/OAuth2TokenTest.php @@ -0,0 +1,40 @@ +createMock(ServerRequestInterface::class); + $serverRequest->expects($this->once()) + ->method('getAttribute') + ->with('oauth_scopes', []) + ->willReturn($scopes); + + $user = new User(); + $rolePrefix = 'ROLE_OAUTH2_'; + $providerKey = 'main'; + $token = new OAuth2Token($serverRequest, $user, $rolePrefix, $providerKey); + + /** @var OAuth2Token $unserializedToken */ + $unserializedToken = unserialize(serialize($token)); + + $this->assertSame($providerKey, $unserializedToken->getProviderKey()); + + $expectedRole = $rolePrefix . strtoupper($scopes[0]); + $this->assertSame([$expectedRole], $token->getRoleNames()); + + $this->assertSame($user->getUsername(), $unserializedToken->getUser()->getUsername()); + $this->assertFalse($unserializedToken->isAuthenticated()); + } +} diff --git a/TrikoderOAuth2Bundle.php b/TrikoderOAuth2Bundle.php index 07e19a54..14e2d58c 100644 --- a/TrikoderOAuth2Bundle.php +++ b/TrikoderOAuth2Bundle.php @@ -8,7 +8,6 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; -use Trikoder\Bundle\OAuth2Bundle\DependencyInjection\CompilerPass\EventDispatcherCompilerPass; use Trikoder\Bundle\OAuth2Bundle\DependencyInjection\Security\OAuth2Factory; use Trikoder\Bundle\OAuth2Bundle\DependencyInjection\TrikoderOAuth2Extension; @@ -42,7 +41,6 @@ private function configureSecurityExtension(ContainerBuilder $container): void private function configureDoctrineExtension(ContainerBuilder $container): void { - $container->addCompilerPass(new EventDispatcherCompilerPass()); $container->addCompilerPass( DoctrineOrmMappingsPass::createXmlMappingDriver( [ diff --git a/UPGRADE.md b/UPGRADE.md index fd05a454..7e4d99eb 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,6 +1,50 @@ # Upgrade Here you will find upgrade steps between major releases. +## From 2.x to 3.x + +### Console command changes + +#### `trikoder:oauth2:clear-expired-tokens` + +The following options have been renamed: + +* `access-tokens-only` has been renamed to `access-tokens` +* `refresh-tokens-only` has been renamed to `refresh-tokens` + +### SQL schema changes + +The bundle makes modifications to the existing schema. You will need to run the Doctrine schema update process to sync the changes: + +```sh +bin/console doctrine:schema:update +``` + +The schema changes include: + +* New `allow_plain_text_pkce` field on the `oauth2_client` table +* `secret` field on the `oauth2_client` table is now nullable + +### Interface changes + +The following interfaces have been changed: + +#### `Trikoder\Bundle\OAuth2Bundle\Manager\AuthorizationCodeManagerInterface` + +- [Added the clearExpired() method](https://github.com/trikoder/oauth2-bundle/blob/v3.0.0/Manager/AuthorizationCodeManagerInterface.php#L15) + +### Method signature changes + +The following method signatures have been changed: + +#### `Trikoder\Bundle\OAuth2Bundle\Model\Client` + +- [Return type for getSecret() is now nullable](https://github.com/trikoder/oauth2-bundle/blob/v3.0.0/Model/Client.php#L60) + +--- + +> **NOTE:** The underlying [league/oauth2-server](https://github.com/thephpleague/oauth2-server) library has been upgraded from version `7.x` to `8.x`. Please check your code if you are directly implementing their interfaces or extending existing non-final classes. + ## From 1.x to 2.x ### PSR-7/17 HTTP transport implementation diff --git a/composer.json b/composer.json index f6becc05..654ee1c7 100644 --- a/composer.json +++ b/composer.json @@ -3,9 +3,17 @@ "type": "symfony-bundle", "description": "Symfony bundle which provides OAuth 2.0 authorization/resource server capabilities.", "keywords": ["oauth2"], - "homepage": "http://www.trikoder.net/", + "homepage": "https://www.trikoder.net/", "license": "MIT", "authors": [ + { + "name": "Antonio Pauletich", + "email": "antonio.pauletich@trikoder.net" + }, + { + "name": "Berislav Balogović", + "email": "berislav.balogovic@trikoder.net" + }, { "name": "Petar Obradović", "email": "petar.obradovic@trikoder.net" @@ -13,27 +21,25 @@ ], "require": { "php": ">=7.2", - "doctrine/doctrine-bundle": "^1.8", - "doctrine/orm": "^2.6", - "league/oauth2-server": "^7.2", - "psr/event-dispatcher": "^1.0", + "doctrine/doctrine-bundle": "^1.8|^2.0", + "doctrine/orm": "^2.7", + "league/oauth2-server": "^8.0", "psr/http-factory": "^1.0", - "sensio/framework-extra-bundle": "^5.3", + "sensio/framework-extra-bundle": "^5.5", "symfony/event-dispatcher-contracts": "^1.1", - "symfony/framework-bundle": "^3.4|^4.2", - "symfony/psr-http-message-bridge": "^1.2", - "symfony/security-bundle": "^3.4|^4.2", + "symfony/framework-bundle": "^4.4|^5.0", + "symfony/psr-http-message-bridge": "^2.0", + "symfony/security-bundle": "^4.4|^5.0", "steverhoades/oauth2-openid-connect-server": "^1.0" }, "require-dev": { "ext-timecop": "*", "ext-xdebug": "*", - "friendsofphp/php-cs-fixer": "^2.15", - "nyholm/psr7": "^1.1", - "phpunit/phpunit": "~8.2.0", - "symfony/browser-kit": "^3.4|^4.2", - "symfony/phpunit-bridge": "^3.4|^4.3", - "zendframework/zend-diactoros": "^2.1" + "laminas/laminas-diactoros": "^2.2", + "nyholm/psr7": "^1.2", + "phpunit/phpunit": "^8.5", + "symfony/browser-kit": "^4.4|^5.0", + "symfony/phpunit-bridge": "^5.0" }, "autoload": { "psr-4": { "Trikoder\\Bundle\\OAuth2Bundle\\": "" }, @@ -56,7 +62,7 @@ }, "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-master": "4.x-dev" } } } diff --git a/docs/basic-setup.md b/docs/basic-setup.md index bd3507b2..2262b5ad 100644 --- a/docs/basic-setup.md +++ b/docs/basic-setup.md @@ -23,6 +23,8 @@ Options: --redirect-uri[=REDIRECT-URI] Sets redirect uri for client. Use this option multiple times to set multiple redirect URIs. (multiple values allowed) --grant-type[=GRANT-TYPE] Sets allowed grant type for client. Use this option multiple times to set multiple grant types. (multiple values allowed) --scope[=SCOPE] Sets allowed scope for client. Use this option multiple times to set multiple scopes. (multiple values allowed) + --public Creates a public client (a client which does not have a secret) + --allow-plain-text-pkce Creates a client which is allowed to create an authorization code grant PKCE request with the "plain" code challenge method ``` @@ -129,7 +131,8 @@ oauth2_restricted: ## Security roles -Once the user gets past the `oauth2` firewall, they will be granted additional roles based on their granted [token scopes](controlling-token-scopes.md). The roles are named in the following format: +Once the user gets past the `oauth2` firewall, they will be granted additional roles based on their granted [token scopes](controlling-token-scopes.md). +By default, the roles are named in the following format: ``` ROLE_OAUTH2_ @@ -147,30 +150,36 @@ public function indexAction() } ``` +> **NOTE:** You can change the `ROLE_OAUTH2_` prefix via the `role_prefix` configuration option described in [Installation section](../README.md#installation) + ## Auth There are two possible reasons for the authentication server to reject a request: - Provided token is expired or invalid (HTTP response 401 `Unauthorized`) - Provided token is valid but scopes are insufficient (HTTP response 403 `Forbidden`) -## Clearing expired access & refresh tokens +## Clearing expired access, refresh tokens and auth codes -To clear expired access & refresh tokens you can use the `trikoder:oauth2:clear-expired-tokens` command. +To clear expired access and refresh tokens and auth codes you can use the `trikoder:oauth2:clear-expired-tokens` command. The command removes all tokens whose expiry time is lesser than the current. ```sh Description: - Clears all expired access and/or refresh tokens + Clears all expired access and/or refresh tokens and/or auth codes Usage: trikoder:oauth2:clear-expired-tokens [options] Options: - -a, --access-tokens-only Clear only access tokens. - -r, --refresh-tokens-only Clear only refresh tokens. + -a, --access-tokens Clear expired access tokens. + -r, --refresh-tokens Clear expired refresh tokens. + -c, --auth-codes Clear expired auth codes. ``` +Not passing any option means that both expired access and refresh tokens as well as expired auth codes +will be cleared. + ## CORS requests For CORS handling, use [NelmioCorsBundle](https://github.com/nelmio/NelmioCorsBundle) From 22ba0a2bc00825442f6abe7fd580242c96a887ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ben=C4=8Do?= Date: Tue, 24 Mar 2020 16:19:30 +0100 Subject: [PATCH 44/44] Update event-dispatcher-constracts constraints --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 654ee1c7..a0398236 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "league/oauth2-server": "^8.0", "psr/http-factory": "^1.0", "sensio/framework-extra-bundle": "^5.5", - "symfony/event-dispatcher-contracts": "^1.1", + "symfony/event-dispatcher-contracts": "^1.1|^2.0", "symfony/framework-bundle": "^4.4|^5.0", "symfony/psr-http-message-bridge": "^2.0", "symfony/security-bundle": "^4.4|^5.0",