diff --git a/Controller/AuthorizationController.php b/Controller/AuthorizationController.php new file mode 100644 index 00000000..20f37310 --- /dev/null +++ b/Controller/AuthorizationController.php @@ -0,0 +1,80 @@ +server = $server; + $this->security = $security; + $this->eventDispatcher = $eventDispatcher; + } + + public function indexAction(ServerRequestInterface $serverRequest, ResponseFactoryInterface $responseFactory): ResponseInterface + { + $serverResponse = $responseFactory->createResponse(); + + try { + $authRequest = $this->server->validateAuthorizationRequest($serverRequest); + $authRequest->setUser($this->getUserEntity()); + + /** @var AuthorizationRequestResolveEvent $event */ + $event = $this->eventDispatcher->dispatch( + OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, + new AuthorizationRequestResolveEvent($authRequest) + ); + + if ($event->hasResponse()) { + return $event->getResponse(); + } + + $authRequest->setAuthorizationApproved($event->getAuthorizationResolution()); + + return $this->server->completeAuthorizationRequest($authRequest, $serverResponse); + } catch (OAuthServerException $e) { + return $e->generateHttpResponse($serverResponse); + } + } + + private function getUserEntity(): User + { + $userEntity = new User(); + + $user = $this->security->getUser(); + if ($user instanceof UserInterface) { + $userEntity->setIdentifier($user->getUsername()); + } + + return $userEntity; + } +} diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 8d5f0de2..46b5cfaa 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -59,6 +59,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() ->booleanNode('enable_client_credentials_grant') ->info('Whether to enable the client credentials grant') ->defaultTrue() @@ -71,6 +76,10 @@ private function createAuthorizationServerNode(): NodeDefinition ->info('Whether to enable the refresh token grant') ->defaultTrue() ->end() + ->booleanNode('enable_auth_code_grant') + ->info('Whether to enable the authorization code grant') + ->defaultTrue() + ->end() ->end() ; diff --git a/DependencyInjection/TrikoderOAuth2Extension.php b/DependencyInjection/TrikoderOAuth2Extension.php index 27e772b4..1f1f59c7 100644 --- a/DependencyInjection/TrikoderOAuth2Extension.php +++ b/DependencyInjection/TrikoderOAuth2Extension.php @@ -164,6 +164,13 @@ private function configureAuthorizationServer(ContainerBuilder $container, array ]); } + if ($config['enable_auth_code_grant']) { + $authorizationServer->addMethodCall('enableGrantType', [ + new Reference('league.oauth2.server.grant.auth_code_grant'), + new Definition(DateInterval::class, [$config['access_token_ttl']]), + ]); + } + $this->configureGrants($container, $config); } @@ -182,6 +189,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) @@ -228,6 +243,11 @@ private function configureDoctrinePersistence(ContainerBuilder $container, array ->replaceArgument('$entityManager', $entityManager) ; + $container + ->getDefinition('trikoder.oauth2.manager.doctrine.authorization_code_manager') + ->replaceArgument('$entityManager', $entityManager) + ; + $container->setParameter('trikoder.oauth2.persistence.doctrine.enabled', true); $container->setParameter('trikoder.oauth2.persistence.doctrine.manager', $entityManagerName); } diff --git a/Event/AuthorizationRequestResolveEvent.php b/Event/AuthorizationRequestResolveEvent.php new file mode 100644 index 00000000..2ad51551 --- /dev/null +++ b/Event/AuthorizationRequestResolveEvent.php @@ -0,0 +1,117 @@ +authorizationRequest = $authorizationRequest; + } + + public function getAuthorizationResolution(): bool + { + return $this->authorizationResolution; + } + + public function resolveAuthorization(bool $authorizationResolution): void + { + $this->authorizationResolution = $authorizationResolution; + $this->response = null; + } + + public function hasResponse(): bool + { + return $this->response instanceof ResponseInterface; + } + + 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 getGrantTypeId(): string + { + return $this->authorizationRequest->getGrantTypeId(); + } + + public function getClient(): ClientEntityInterface + { + return $this->authorizationRequest->getClient(); + } + + public function getUser(): UserEntityInterface + { + return $this->authorizationRequest->getUser(); + } + + /** + * @return ScopeEntityInterface[] + */ + public function getScopes(): array + { + return $this->authorizationRequest->getScopes(); + } + + public function isAuthorizationApproved(): bool + { + return $this->authorizationRequest->isAuthorizationApproved(); + } + + public function getRedirectUri(): ?string + { + return $this->authorizationRequest->getRedirectUri(); + } + + public function getState(): ?string + { + return $this->authorizationRequest->getState(); + } + + public function getCodeChallenge(): string + { + return $this->authorizationRequest->getCodeChallenge(); + } + + public function getCodeChallengeMethod(): string + { + return $this->authorizationRequest->getCodeChallengeMethod(); + } +} diff --git a/League/Entity/AuthCode.php b/League/Entity/AuthCode.php new file mode 100644 index 00000000..7af21cdc --- /dev/null +++ b/League/Entity/AuthCode.php @@ -0,0 +1,17 @@ +authorizationCodeManager = $authorizationCodeManager; + $this->clientManager = $clientManager; + $this->scopeConverter = $scopeConverter; + } + + /** + * {@inheritdoc} + */ + public function getNewAuthCode() + { + return new AuthCode(); + } + + /** + * {@inheritdoc} + */ + public function persistNewAuthCode(AuthCodeEntityInterface $authCode) + { + $authorizationCode = $this->authorizationCodeManager->find($authCode->getIdentifier()); + + if (null !== $authorizationCode) { + throw UniqueTokenIdentifierConstraintViolationException::create(); + } + + $authorizationCode = $this->buildAuthorizationCode($authCode); + + $this->authorizationCodeManager->save($authorizationCode); + } + + /** + * {@inheritdoc} + */ + public function revokeAuthCode($codeId) + { + $authorizationCode = $this->authorizationCodeManager->find($codeId); + + if (null === $codeId) { + return; + } + + $authorizationCode->revoke(); + + $this->authorizationCodeManager->save($authorizationCode); + } + + /** + * {@inheritdoc} + */ + public function isAuthCodeRevoked($codeId) + { + $authorizationCode = $this->authorizationCodeManager->find($codeId); + + if (null === $authorizationCode) { + return true; + } + + return $authorizationCode->isRevoked(); + } + + private function buildAuthorizationCode(AuthCode $authCode): AuthorizationCode + { + $client = $this->clientManager->find($authCode->getClient()->getIdentifier()); + + $authorizationCode = new AuthorizationCode( + $authCode->getIdentifier(), + $authCode->getExpiryDateTime(), + $client, + $authCode->getUserIdentifier(), + $this->scopeConverter->toDomainArray($authCode->getScopes()) + ); + + return $authorizationCode; + } +} diff --git a/Manager/AuthorizationCodeManagerInterface.php b/Manager/AuthorizationCodeManagerInterface.php new file mode 100644 index 00000000..4775c9d9 --- /dev/null +++ b/Manager/AuthorizationCodeManagerInterface.php @@ -0,0 +1,14 @@ +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/AuthorizationCodeManager.php b/Manager/InMemory/AuthorizationCodeManager.php new file mode 100644 index 00000000..a51f36d1 --- /dev/null +++ b/Manager/InMemory/AuthorizationCodeManager.php @@ -0,0 +1,26 @@ +authorizationCodes[$identifier] ?? null; + } + + public function save(AuthorizationCode $authorizationCode): void + { + $this->authorizationCodes[$authorizationCode->getIdentifier()] = $authorizationCode; + } +} diff --git a/Model/AuthorizationCode.php b/Model/AuthorizationCode.php new file mode 100644 index 00000000..5300c551 --- /dev/null +++ b/Model/AuthorizationCode.php @@ -0,0 +1,99 @@ +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/OAuth2Events.php b/OAuth2Events.php index e6334793..8894eb9d 100644 --- a/OAuth2Events.php +++ b/OAuth2Events.php @@ -21,4 +21,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 approve or deny the authorization request, or set the uri where + * must be redirected to resolve the authorization request. + */ + public const AUTHORIZATION_REQUEST_RESOLVE = 'trikoder.oauth2.authorization_request_resolve'; } diff --git a/OAuth2Grants.php b/OAuth2Grants.php index 08cc063e..5b2f54cd 100644 --- a/OAuth2Grants.php +++ b/OAuth2Grants.php @@ -43,11 +43,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/README.md b/README.md index f8144efc..ea7c7925 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,10 @@ This package is currently in the active development. # Whether to enable the refresh token grant enable_refresh_token_grant: true + # 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 @@ -118,6 +122,16 @@ This package is currently in the active development. 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 +security: + access_control: + - { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED } +``` + ## Configuration * [Basic setup](docs/basic-setup.md) diff --git a/Resources/config/doctrine/model/AuthorizationCode.orm.xml b/Resources/config/doctrine/model/AuthorizationCode.orm.xml new file mode 100644 index 00000000..1beaa55b --- /dev/null +++ b/Resources/config/doctrine/model/AuthorizationCode.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 1a41500e..5ce84f77 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -29,6 +29,11 @@ + + + + + @@ -66,6 +71,19 @@ + + + + + + + + + + + + + diff --git a/Resources/config/storage/doctrine.xml b/Resources/config/storage/doctrine.xml index 780a23fe..a411da0d 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..1e3ceb29 100644 --- a/Resources/config/storage/in_memory.xml +++ b/Resources/config/storage/in_memory.xml @@ -7,11 +7,13 @@ + + diff --git a/Tests/Acceptance/AuthorizationEndpointTest.php b/Tests/Acceptance/AuthorizationEndpointTest.php new file mode 100644 index 00000000..fff4ac0b --- /dev/null +++ b/Tests/Acceptance/AuthorizationEndpointTest.php @@ -0,0 +1,155 @@ +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 testSuccessfulCodeRequest() + { + $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', + ] + ); + + 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 testCodeRequestRedirectToResolutionUri() + { + $this->client + ->getContainer() + ->get('event_dispatcher') + ->addListener(OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, function (AuthorizationRequestResolveEvent $event) { + $response = (new Response())->withStatus(302)->withHeader('Location', '/authorize/consent'); + $event->setResponse($response); + }); + + 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, + ] + ); + + timecop_return(); + + $response = $this->client->getResponse(); + + $this->assertSame(302, $response->getStatusCode()); + $redirectUri = $response->headers->get('Location'); + $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( + 'GET', + '/authorize' + ); + + $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/ClearExpiredTokensCommandTest.php b/Tests/Acceptance/ClearExpiredTokensCommandTest.php index 13f51fc9..abdd198a 100644 --- a/Tests/Acceptance/ClearExpiredTokensCommandTest.php +++ b/Tests/Acceptance/ClearExpiredTokensCommandTest.php @@ -8,6 +8,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; 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; @@ -25,7 +26,8 @@ protected function setUp(): void $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(RefreshTokenManagerInterface::class), + $this->client->getContainer()->get(AuthorizationCodeManagerInterface::class) ); } diff --git a/Tests/Acceptance/SecurityLayerTest.php b/Tests/Acceptance/SecurityLayerTest.php index f54b2e23..40f5bd0b 100644 --- a/Tests/Acceptance/SecurityLayerTest.php +++ b/Tests/Acceptance/SecurityLayerTest.php @@ -5,6 +5,7 @@ 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; @@ -21,7 +22,8 @@ protected function setUp(): void $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(RefreshTokenManagerInterface::class), + $this->client->getContainer()->get(AuthorizationCodeManagerInterface::class) ); } diff --git a/Tests/Acceptance/TokenEndpointTest.php b/Tests/Acceptance/TokenEndpointTest.php index 494a2b3d..892bd896 100644 --- a/Tests/Acceptance/TokenEndpointTest.php +++ b/Tests/Acceptance/TokenEndpointTest.php @@ -7,6 +7,7 @@ use DateTime; use Trikoder\Bundle\OAuth2Bundle\Event\UserResolveEvent; 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; @@ -23,7 +24,8 @@ protected function setUp(): void $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(RefreshTokenManagerInterface::class), + $this->client->getContainer()->get(AuthorizationCodeManagerInterface::class) ); } @@ -116,6 +118,37 @@ public function testSuccessfulRefreshTokenRequest() $this->assertNotEmpty($jsonResponse['refresh_token']); } + public function testSuccessfulAuthorizationCodeRequest() + { + $authCode = $this->client + ->getContainer() + ->get(AuthorizationCodeManagerInterface::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 06aa66a7..5ba78ecc 100644 --- a/Tests/Fixtures/FixtureFactory.php +++ b/Tests/Fixtures/FixtureFactory.php @@ -6,12 +6,15 @@ use DateTime; 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\AccessToken; +use Trikoder\Bundle\OAuth2Bundle\Model\AuthorizationCode; 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; @@ -37,12 +40,18 @@ final class FixtureFactory public const FIXTURE_REFRESH_TOKEN_REVOKED = '63641841630c2e4d747e0f9ebe12ee04424e322874b8e68ef69fd58f1899ef70beb09733e23928a6'; public const FIXTURE_REFRESH_TOKEN_WITH_SCOPES = 'e47d593ed661840b3633e4577c3261ef57ba225be193b190deb69ee9afefdc19f54f890fbdda59f5'; + 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'; 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_CLIENT_FIRST_REDIRECT_URI = 'https://example.org/oauth2/redirect-uri'; + public const FIXTURE_SCOPE_FIRST = 'fancy'; public const FIXTURE_SCOPE_SECOND = 'rock'; @@ -60,7 +69,8 @@ public static function initializeFixtures( ScopeManagerInterface $scopeManager, ClientManagerInterface $clientManager, AccessTokenManagerInterface $accessTokenManager, - RefreshTokenManagerInterface $refreshTokenManager + RefreshTokenManagerInterface $refreshTokenManager, + AuthorizationCodeManagerInterface $authCodeManager ): void { foreach (self::createScopes() as $scope) { $scopeManager->save($scope); @@ -77,6 +87,10 @@ public static function initializeFixtures( foreach (self::createRefreshTokens($accessTokenManager) as $refreshToken) { $refreshTokenManager->save($refreshToken); } + + foreach (self::createAuthorizationCodes($clientManager) as $authorizationCode) { + $authCodeManager->save($authorizationCode); + } } /** @@ -187,6 +201,40 @@ private static function createRefreshTokens(AccessTokenManagerInterface $accessT return $refreshTokens; } + /** + * @return AuthorizationCode[] + */ + public static function createAuthorizationCodes(ClientManagerInterface $clientManager): array + { + $authorizationCodes = []; + + $authorizationCodes[] = new AuthorizationCode( + self::FIXTURE_AUTH_CODE, + new DateTime('+2 minute'), + $clientManager->find(self::FIXTURE_CLIENT_FIRST), + self::FIXTURE_USER, + [] + ); + + $authorizationCodes[] = new AuthorizationCode( + self::FIXTURE_AUTH_CODE_DIFFERENT_CLIENT, + new DateTime('+2 minute'), + $clientManager->find(self::FIXTURE_CLIENT_SECOND), + self::FIXTURE_USER, + [] + ); + + $authorizationCodes[] = new AuthorizationCode( + self::FIXTURE_AUTH_CODE_EXPIRED, + new DateTime('-30 minute'), + $clientManager->find(self::FIXTURE_CLIENT_FIRST), + self::FIXTURE_USER, + [] + ); + + return $authorizationCodes; + } + /** * @return Client[] */ @@ -194,7 +242,8 @@ private 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 6242e6c3..806cd63d 100644 --- a/Tests/Fixtures/User.php +++ b/Tests/Fixtures/User.php @@ -22,7 +22,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 ecabddf4..ff6dbd58 100644 --- a/Tests/Integration/AbstractIntegrationTest.php +++ b/Tests/Integration/AbstractIntegrationTest.php @@ -4,34 +4,42 @@ 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; use League\OAuth2\Server\Repositories\UserRepositoryInterface; use League\OAuth2\Server\ResourceServer; use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Symfony\Bundle\FrameworkBundle\Tests\TestCase; 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; use Trikoder\Bundle\OAuth2Bundle\League\Repository\UserRepository; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; +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\AuthorizationCodeManager; use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\ClientManager; use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\RefreshTokenManager; use Trikoder\Bundle\OAuth2Bundle\Manager\InMemory\ScopeManager; @@ -58,6 +66,11 @@ abstract class AbstractIntegrationTest extends TestCase */ protected $accessTokenManager; + /** + * @var AuthorizationCodeManagerInterface + */ + protected $authCodeManager; + /** * @var RefreshTokenManagerInterface */ @@ -92,6 +105,7 @@ protected function setUp(): void $this->clientManager = new ClientManager(); $this->accessTokenManager = new AccessTokenManager(); $this->refreshTokenManager = new RefreshTokenManager(); + $this->authCodeManager = new AuthorizationCodeManager(); $this->eventDispatcher = new EventDispatcher(); $scopeConverter = new ScopeConverter(); @@ -100,13 +114,15 @@ protected function setUp(): void $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); @@ -168,7 +184,18 @@ protected function createResourceRequest(string $jwtToken): ServerRequestInterfa ; } - protected function handleAuthorizationRequest(ServerRequestInterface $serverRequest): array + protected function createAuthorizeRequest(?string $credentials, array $query = []): ServerRequestInterface + { + $serverRequest = $this + ->psrFactory + ->createServerRequest('', '') + ->withQueryParams($query) + ; + + return \is_string($credentials) ? $serverRequest->withHeader('Authorization', sprintf('Basic %s', base64_encode($credentials))) : $serverRequest; + } + + protected function handleTokenRequest(ServerRequestInterface $serverRequest): array { $response = $this->psrFactory->createResponse(); @@ -192,12 +219,42 @@ protected function handleResourceRequest(ServerRequestInterface $serverRequest): return $serverRequest; } + protected function handleAuthorizationRequest(ServerRequestInterface $serverRequest, $approved = true): ResponseInterface + { + $response = $this->psrFactory->createResponse(); + + try { + $authRequest = $this->authorizationServer->validateAuthorizationRequest($serverRequest); + $user = new User(); + $user->setIdentifier('user'); + $authRequest->setUser($user); + $authRequest->setAuthorizationApproved($approved); + + $response = $this->authorizationServer->completeAuthorizationRequest($authRequest, $response); + } catch (OAuthServerException $e) { + $response = $e->generateHttpResponse($response); + } + + return $response; + } + + protected function extractQueryDataFromUri(string $uri): array + { + $uriObject = $this->psrFactory->createUri($uri); + + $data = []; + parse_str($uriObject->getQuery(), $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, @@ -210,6 +267,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 224e350b..eb68b3ed 100644 --- a/Tests/Integration/AuthorizationServerTest.php +++ b/Tests/Integration/AuthorizationServerTest.php @@ -21,7 +21,8 @@ protected function setUp(): void $this->scopeManager, $this->clientManager, $this->accessTokenManager, - $this->refreshTokenManager + $this->refreshTokenManager, + $this->authCodeManager ); } @@ -31,7 +32,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); @@ -45,7 +46,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); @@ -57,7 +58,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']); @@ -71,7 +72,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']); @@ -84,7 +85,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']); @@ -97,7 +98,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']); @@ -110,7 +111,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']); @@ -124,7 +125,7 @@ public function testRestrictedScopeClient(): void 'scope' => 'fancy rock', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_scope', $response['error']); @@ -138,7 +139,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']); @@ -153,7 +154,7 @@ public function testInvalidScope(): void 'scope' => 'non_existing', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_scope', $response['error']); @@ -169,7 +170,7 @@ public function testValidClientCredentialsGrant(): void timecop_freeze(new DateTime()); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); timecop_return(); @@ -193,7 +194,7 @@ public function testValidClientCredentialsGrantWithScope(): void timecop_freeze(new DateTime()); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); timecop_return(); @@ -224,7 +225,7 @@ public function testValidClientCredentialsGrantWithInheritedScope(): void timecop_freeze(new DateTime()); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); timecop_return(); @@ -256,7 +257,7 @@ public function testValidClientCredentialsGrantWithRequestedScope(): void timecop_freeze(new DateTime()); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); timecop_return(); @@ -293,7 +294,7 @@ public function testValidPasswordGrant(): void timecop_freeze(new DateTime()); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); timecop_return(); @@ -325,7 +326,7 @@ public function testInvalidCredentialsPasswordGrant(): void 'password' => 'pass', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_credentials', $response['error']); @@ -339,7 +340,7 @@ public function testMissingUsernameFieldPasswordGrant(): void 'password' => 'pass', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_request', $response['error']); @@ -354,7 +355,7 @@ public function testMissingPasswordFieldPasswordGrant(): void 'username' => 'user', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_request', $response['error']); @@ -374,7 +375,7 @@ public function testValidRefreshGrant(): void timecop_freeze(new DateTime()); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); timecop_return(); @@ -405,7 +406,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']); @@ -423,7 +424,7 @@ public function testDifferentScopeRefreshGrant(): void 'refresh_token' => TestHelper::generateEncryptedPayload($existingRefreshToken), ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_scope', $response['error']); @@ -440,7 +441,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']); @@ -457,7 +458,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']); @@ -471,7 +472,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']); @@ -486,11 +487,241 @@ public function testInvalidPayloadRefreshGrant(): void 'refresh_token' => 'invalid', ]); - $response = $this->handleAuthorizationRequest($request); + $response = $this->handleTokenRequest($request); // Response assertions. $this->assertSame('invalid_request', $response['error']); $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->handleAuthorizationRequest($request); + + // Response assertions. + $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 + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'foo', + 'state' => 'quzbaz', + ]); + + $response = $this->handleAuthorizationRequest($request); + + // Response assertions. + $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 + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'foo', + 'redirect_uri' => 'https://example.org/oauth2/redirect-uri', + ]); + + $response = $this->handleAuthorizationRequest($request); + + // Response assertions. + $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 + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'foo', + 'scope' => 'non_existing', + ]); + + $response = $this->handleAuthorizationRequest($request); + + // Response assertions. + $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 + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'foo', + 'redirect_uri' => 'https://example.org/oauth2/other-uri', + ]); + + $response = $this->handleAuthorizationRequest($request); + + // Response assertions. + $this->assertSame(401, $response->getStatusCode()); + $responseData = json_decode((string) $response->getBody(), true); + $this->assertSame('invalid_client', $responseData['error']); + $this->assertSame('Client authentication failed', $responseData['message']); + } + + public function testDeniedCodeRequest(): void + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'foo', + ]); + + $response = $this->handleAuthorizationRequest($request, false); + + // Response assertions. + $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 + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'yolo', + ]); + + $response = $this->handleAuthorizationRequest($request, false); + + // Response assertions. + $this->assertSame(401, $response->getStatusCode()); + $responseData = json_decode((string) $response->getBody(), true); + $this->assertSame('invalid_client', $responseData['error']); + $this->assertSame('Client authentication failed', $responseData['message']); + } + + public function testCodeRequestWithInactiveClient(): void + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'baz_inactive', + ]); + + $response = $this->handleAuthorizationRequest($request, false); + + // Response assertions. + $this->assertSame(401, $response->getStatusCode()); + $responseData = json_decode((string) $response->getBody(), true); + $this->assertSame('invalid_client', $responseData['error']); + $this->assertSame('Client authentication failed', $responseData['message']); + } + + public function testCodeRequestWithRestrictedGrantClient(): void + { + $request = $this->createAuthorizeRequest(null, [ + 'response_type' => 'code', + 'client_id' => 'qux_restricted', + ]); + + $response = $this->handleAuthorizationRequest($request, false); + + // Response assertions. + $this->assertSame(401, $response->getStatusCode()); + $responseData = json_decode((string) $response->getBody(), true); + $this->assertSame('invalid_client', $responseData['error']); + $this->assertSame('Client authentication failed', $responseData['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->handleTokenRequest($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->handleTokenRequest($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->handleTokenRequest($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->handleTokenRequest($request); + + // Response assertions. + $this->assertSame('invalid_client', $response['error']); + $this->assertSame('Client authentication failed', $response['message']); + } } 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/TestHelper.php b/Tests/TestHelper.php index 3a55ff7f..a69fc0fa 100644 --- a/Tests/TestHelper.php +++ b/Tests/TestHelper.php @@ -10,10 +10,12 @@ 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\AuthorizationCode as AuthorizationCodeModel; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken as RefreshTokenModel; final class TestHelper @@ -40,6 +42,26 @@ public static function generateEncryptedPayload(RefreshTokenModel $refreshToken) } } + public static function generateEncryptedAuthCodePayload(AuthorizationCodeModel $authCode): ?string + { + $payload = json_encode([ + 'client_id' => $authCode->getClient()->getIdentifier(), + 'redirect_uri' => (string) $authCode->getClient()->getRedirectUris()[0], + 'auth_code_id' => $authCode->getIdentifier(), + 'scopes' => (new ScopeConverter())->toDomainArray($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 a7ca02cc..3c1eb473 100644 --- a/Tests/TestKernel.php +++ b/Tests/TestKernel.php @@ -18,6 +18,7 @@ use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Routing\RouteCollectionBuilder; 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; @@ -225,6 +226,15 @@ private function exposeManagerServices(ContainerBuilder $container): void ) ->setPublic(true) ; + + $container + ->getDefinition( + $container + ->getAlias(AuthorizationCodeManagerInterface::class) + ->setPublic(true) + ) + ->setPublic(true) + ; } private function configurePsrHttpFactory(ContainerBuilder $container): void