diff --git a/CHANGELOG.md b/CHANGELOG.md index a28c5e7..82c399d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,15 @@ All notable changes to this project will be documented in this file, in reverse ### Added -- Nothing. +- [#42](https://github.com/zendframework/zend-expressive-authentication-oauth2/pull/42) Adds `TokenEndpointHandler`, + `AuthorizationMiddleware` and `AuthorizationHandler` in the `Zend\Expressive\Authentication\OAuth2` namespace + to [implement an authorization server](docs/book/authorization-server.md). ### Changed -- Nothing. +- [#42](https://github.com/zendframework/zend-expressive-authentication-oauth2/pull/42) Splits + `Zend\Expressive\Authentication\OAuth2\OAuth2Middleware` into individual implementations that allow + [OAuth RFC-6749](https://tools.ietf.org/html/rfc6749) compliant authorization server implementations. ### Deprecated @@ -18,7 +22,8 @@ All notable changes to this project will be documented in this file, in reverse ### Removed -- Nothing. +- [#42](https://github.com/zendframework/zend-expressive-authentication-oauth2/pull/42) Removes + `Zend\Expressive\Authentication\OAuth2\OAuth2Middleware`. ### Fixed diff --git a/docs/book/authorization-server.md b/docs/book/authorization-server.md new file mode 100644 index 0000000..3eb298c --- /dev/null +++ b/docs/book/authorization-server.md @@ -0,0 +1,113 @@ +# Implement an authorization server + +This library provides the basics to implement an authorization server +for your application. + +Since there are authorization flows that require user interaction, +your application is expected to provide the middleware to handle this. + +## Add the token endpoint + +This is the most simple part, since this library provides +`Zend\Expressive\Authentication\OAuth2\TokenHandler` to deal with it. + +This endpoint must accept POST requests. + +For example: + +```php +use Zend\Expressive\Authentication\OAuth2; + +$app->route('/oauth2/token', OAuth2\TokenHandler::class, ['POST']); +``` + +## Add the authorization endpoint + +The authorization endpoint is an url of to which the client redirects +to obtain an access token or authorization code. + +This endpoint must accept GET requests and should: + + - Validate the request (especially for a valid client id and redirect url) + - Make sure the User is authenticated (for example by showing a login + prompt if needed) + - Optionally request the users consent to grant access to the client + - Redirect to a specified url of the client with success or error information + +The first and the last part is provided by this library. + +For example, to add the authorization endpoint you can declare a middleware pipe +to compose these parts: + +```php +use Zend\Expressive\Authentication\OAuth2; + +$app->route('/oauth2/authorize', [ + OAuth2\AuthorizatonMiddleware, + + // The followig middleware is provided by your application (see below) + Application\OAuthAuthorizationMiddleware::class, + + OAuth2\AuthorizationHandler +], ['GET', 'POST']); +``` + +In your `Application\OAuthAuthorizationMiddleware`, you'll have access +to the `League\OAuth2\Server\RequestTypes\AuthorizationRequest` via the +psr-7 request. Your middleware should populate the user entity with `setUser()` and the +user's consent decision with `setAuthorizationApproved()` to this authorization +request instance. + +```php +getAttribute('authenticated_user'); + + // Assume some middleware handles and populates a session + // container + $session = $request->getAttribute('session'); + + // This is populated by the previous middleware + /** @var AuthorizationRequest $authRequest */ + $authRequest = $request->getAttribute(AuthorizationRequest::class); + + // the user is authenticated + if ($user) { + $authRequest->setUser($user); + + // Assume all clients are trusted, but you could + // handle consent here or within the next middleware + // as needed + $authRequest->setAuthorizationApproved(true); + + return $handler->handle($request); + } + + // The user is not authenticated, show login form ... + + // Store the auth request state + // NOTE: Do not attempt to serialize or store the authorization + // request object. Store the query parameters instead and redirect + // with these to this endpoint again to replay the request. + $session['oauth2_request_params'] = $request->getQueryParams(); + + return new RedirectResponse('/oauth2/login'); + } +} +``` + diff --git a/docs/book/usage.md b/docs/book/usage.md index 1d34e15..2d47cd1 100644 --- a/docs/book/usage.md +++ b/docs/book/usage.md @@ -53,3 +53,8 @@ $app->post('/api/users', [ App\Action\AddUserAction::class, ], 'api.add.user'); ``` + +# Providing an authorization server + +See the chapter [Authorization server](authorization-server.md) for details on how +to implement this. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 105ee8e..bb56eee 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,6 +4,7 @@ pages: - index.md - Introduction: intro.md - Usage: usage.md + - "Authorization server": authorization-server.md - Grant: - "Client credentials": grant/client_credentials.md - "Password": grant/password.md diff --git a/src/AuthorizationHandler.php b/src/AuthorizationHandler.php new file mode 100644 index 0000000..98478cb --- /dev/null +++ b/src/AuthorizationHandler.php @@ -0,0 +1,54 @@ +server = $server; + $this->responseFactory = function () use ($responseFactory): ResponseInterface { + return $responseFactory(); + }; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $authRequest = $request->getAttribute(AuthorizationRequest::class); + return $this->server->completeAuthorizationRequest($authRequest, ($this->responseFactory)()); + } +} diff --git a/src/AuthorizationHandlerFactory.php b/src/AuthorizationHandlerFactory.php new file mode 100644 index 0000000..ca28a7b --- /dev/null +++ b/src/AuthorizationHandlerFactory.php @@ -0,0 +1,26 @@ +get(AuthorizationServer::class), + $container->get(ResponseInterface::class) + ); + } +} diff --git a/src/AuthorizationMiddleware.php b/src/AuthorizationMiddleware.php new file mode 100644 index 0000000..5a4502f --- /dev/null +++ b/src/AuthorizationMiddleware.php @@ -0,0 +1,79 @@ +server = $server; + $this->responseFactory = function () use ($responseFactory) : ResponseInterface { + return $responseFactory(); + }; + } + + /** + * {@inheritDoc} + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + { + $response = ($this->responseFactory)(); + + try { + $authRequest = $this->server->validateAuthorizationRequest($request); + + // The next handler must take care of providing the + // authenticated user and the approval + $authRequest->setAuthorizationApproved(false); + + return $handler->handle($request->withAttribute(AuthorizationRequest::class, $authRequest)); + } catch (OAuthServerException $exception) { + // The validation throws this exception if the request is not valid + // for example when the client id is invalid + return $exception->generateHttpResponse($response); + } catch (\Exception $exception) { + return (new OAuthServerException($exception->getMessage(), 0, 'unknown_error', 500)) + ->generateHttpResponse($response); + } + } +} diff --git a/src/OAuth2MiddlewareFactory.php b/src/AuthorizationMiddlewareFactory.php similarity index 53% rename from src/OAuth2MiddlewareFactory.php rename to src/AuthorizationMiddlewareFactory.php index 4a71861..c27d922 100644 --- a/src/OAuth2MiddlewareFactory.php +++ b/src/AuthorizationMiddlewareFactory.php @@ -14,25 +14,12 @@ use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; -use function sprintf; - -class OAuth2MiddlewareFactory +final class AuthorizationMiddlewareFactory { - public function __invoke(ContainerInterface $container) : OAuth2Middleware + public function __invoke(ContainerInterface $container) : AuthorizationMiddleware { - $authServer = $container->has(AuthorizationServer::class) - ? $container->get(AuthorizationServer::class) - : null; - - if (null === $authServer) { - throw new Exception\InvalidConfigException(sprintf( - "The %s service is missing", - AuthorizationServer::class - )); - } - - return new OAuth2Middleware( - $authServer, + return new AuthorizationMiddleware( + $container->get(AuthorizationServer::class), $container->get(ResponseInterface::class) ); } diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index 4a7aaf5..d59fc0a 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -62,7 +62,9 @@ public function getDependencies() : array AuthenticationInterface::class => OAuth2Adapter::class ], 'factories' => [ - OAuth2Middleware::class => OAuth2MiddlewareFactory::class, + AuthorizationMiddleware::class => AuthorizationMiddlewareFactory::class, + AuthorizationHandler::class => AuthorizationServerFactory::class, + TokenEndpointHandler::class => TokenEndpointHandlerFactory::class, OAuth2Adapter::class => OAuth2AdapterFactory::class, AuthorizationServer::class => AuthorizationServerFactory::class, ResourceServer::class => ResourceServerFactory::class, @@ -90,7 +92,7 @@ public function getRoutes() : array [ 'name' => 'oauth', 'path' => '/oauth', - 'middleware' => OAuth2Middleware::class, + 'middleware' => AuthorizationMiddleware::class, 'allowed_methods' => ['GET', 'POST'] ], ]; diff --git a/src/OAuth2Middleware.php b/src/OAuth2Middleware.php deleted file mode 100644 index e3d1acd..0000000 --- a/src/OAuth2Middleware.php +++ /dev/null @@ -1,125 +0,0 @@ -server = $server; - $this->responseFactory = function () use ($responseFactory) : ResponseInterface { - return $responseFactory(); - }; - } - - /** - * {@inheritDoc} - */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface - { - $method = $request->getMethod(); - switch (strtoupper($method)) { - case 'GET': - return $this->authorizationRequest($request); - case 'POST': - return $this->accessTokenRequest($request); - } - return ($this->responseFactory)()->withStatus(501); // Method not implemented - } - - /** - * Authorize the request and return an authorization code - * Used for authorization code grant and implicit grant - * - * @see https://oauth2.thephpleague.com/authorization-server/auth-code-grant/ - * @see https://oauth2.thephpleague.com/authorization-server/implicit-grant/ - * - * @param ServerRequestInterface $request - * @return ResponseInterface - */ - protected function authorizationRequest(ServerRequestInterface $request) : ResponseInterface - { - // Create a new response for the request - $response = ($this->responseFactory)(); - - try { - // Validate the HTTP request and return an AuthorizationRequest object. - $authRequest = $this->server->validateAuthorizationRequest($request); - - // The auth request object can be serialized and saved into a user's session. - // You will probably want to redirect the user at this point to a login endpoint. - - // Once the user has logged in set the user on the AuthorizationRequest - $authRequest->setUser(new UserEntity('guest')); // an instance of UserEntityInterface - - // At this point you should redirect the user to an authorization page. - // This form will ask the user to approve the client and the scopes requested. - - // Once the user has approved or denied the client update the status - // (true = approved, false = denied) - $authRequest->setAuthorizationApproved(true); - - // Return the HTTP redirect response - return $this->server->completeAuthorizationRequest($authRequest, $response); - } catch (OAuthServerException $exception) { - return $exception->generateHttpResponse($response); - } catch (\Exception $exception) { - return (new OAuthServerException($exception->getMessage(), 0, 'unknown_error', 500)) - ->generateHttpResponse($response); - } - } - - /** - * Request an access token - * Used for client credential grant, password grant, and refresh token grant - * - * @see https://oauth2.thephpleague.com/authorization-server/client-credentials-grant/ - * @see https://oauth2.thephpleague.com/authorization-server/resource-owner-password-credentials-grant/ - * @see https://oauth2.thephpleague.com/authorization-server/refresh-token-grant/ - * - * @param ServerRequestInterface $request - * @return ResponseInterface - */ - protected function accessTokenRequest(ServerRequestInterface $request) : ResponseInterface - { - // Create a new response for the request - $response = ($this->responseFactory)(); - - try { - return $this->server->respondToAccessTokenRequest($request, $response); - } catch (OAuthServerException $exception) { - return $exception->generateHttpResponse($response); - } catch (\Exception $exception) { - return (new OAuthServerException($exception->getMessage(), 0, 'unknown_error', 500)) - ->generateHttpResponse($response); - } - } -} diff --git a/src/TokenEndpointHandler.php b/src/TokenEndpointHandler.php new file mode 100644 index 0000000..4c043dc --- /dev/null +++ b/src/TokenEndpointHandler.php @@ -0,0 +1,76 @@ +server = $server; + $this->responseFactory = $responseFactory; + } + + private function createResponse(): ResponseInterface + { + return ($this->responseFactory)(); + } + + /** + * Request an access token + * + * Used for client credential grant, password grant, and refresh token grant + * + * @see https://oauth2.thephpleague.com/authorization-server/client-credentials-grant/ + * @see https://oauth2.thephpleague.com/authorization-server/resource-owner-password-credentials-grant/ + * @see https://oauth2.thephpleague.com/authorization-server/refresh-token-grant/ + * @see https://tools.ietf.org/html/rfc6749#section-3.2 + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $response = $this->createResponse(); + + try { + return $this->server->respondToAccessTokenRequest($request, $response); + } catch (OAuthServerException $exception) { + return $exception->generateHttpResponse($response); + } + } +} diff --git a/src/TokenEndpointHandlerFactory.php b/src/TokenEndpointHandlerFactory.php new file mode 100644 index 0000000..59fee29 --- /dev/null +++ b/src/TokenEndpointHandlerFactory.php @@ -0,0 +1,26 @@ +get(AuthorizationServer::class), + $container->get(ResponseInterface::class) + ); + } +} diff --git a/test/OAuth2MiddlewareFactoryTest.php b/test/AuthorizationHandlerFactoryTest.php similarity index 68% rename from test/OAuth2MiddlewareFactoryTest.php rename to test/AuthorizationHandlerFactoryTest.php index 021938a..47c6ecb 100644 --- a/test/OAuth2MiddlewareFactoryTest.php +++ b/test/AuthorizationHandlerFactoryTest.php @@ -17,19 +17,18 @@ use Psr\Http\Message\ResponseInterface; use stdClass; use TypeError; -use Zend\Expressive\Authentication\OAuth2\Exception\InvalidConfigException; -use Zend\Expressive\Authentication\OAuth2\OAuth2Middleware; -use Zend\Expressive\Authentication\OAuth2\OAuth2MiddlewareFactory; +use Zend\Expressive\Authentication\OAuth2\AuthorizationHandler; +use Zend\Expressive\Authentication\OAuth2\AuthorizationHandlerFactory; /** - * @covers \Zend\Expressive\Authentication\OAuth2\OAuth2MiddlewareFactory + * @covers \Zend\Expressive\Authentication\OAuth2\AuthorizationHandlerFactory */ -class OAuth2MiddlewareFactoryTest extends TestCase +class AuthorizationHandlerFactoryTest extends TestCase { /** @var AuthorizationServer|ObjectProphecy */ private $authServer; - /** @var AuthServer|ObjectProphecy */ + /** @var ContainerInterface|ObjectProphecy */ private $container; /** @var ResponseInterface|ObjectProphecy */ @@ -44,23 +43,28 @@ public function setUp() public function testConstructor() { - $factory = new OAuth2MiddlewareFactory(); - $this->assertInstanceOf(OAuth2MiddlewareFactory::class, $factory); + $factory = new AuthorizationHandlerFactory(); + $this->assertInstanceOf(AuthorizationHandlerFactory::class, $factory); } - public function testInvokeWithEmptyContainer() + public function testRaisesTypeErrorForInvalidAuthorizationServer() { - $factory = new OAuth2MiddlewareFactory(); + $this->container + ->get(AuthorizationServer::class) + ->willReturn(new stdClass()); + $this->container + ->get(ResponseInterface::class) + ->willReturn(function () { + }); - $this->expectException(InvalidConfigException::class); - $middleware = $factory($this->container->reveal()); + $factory = new AuthorizationHandlerFactory(); + + $this->expectException(TypeError::class); + $factory($this->container->reveal()); } public function testFactoryRaisesTypeErrorForNonCallableResponseFactory() { - $this->container - ->has(AuthorizationServer::class) - ->willReturn(true); $this->container ->get(AuthorizationServer::class) ->willReturn($this->authServer->reveal()); @@ -68,7 +72,7 @@ public function testFactoryRaisesTypeErrorForNonCallableResponseFactory() ->get(ResponseInterface::class) ->willReturn(new stdClass()); - $factory = new OAuth2MiddlewareFactory(); + $factory = new AuthorizationHandlerFactory(); $this->expectException(TypeError::class); $factory($this->container->reveal()); @@ -76,9 +80,6 @@ public function testFactoryRaisesTypeErrorForNonCallableResponseFactory() public function testFactoryRaisesTypeErrorWhenResponseServiceProvidesResponseInstance() { - $this->container - ->has(AuthorizationServer::class) - ->willReturn(true); $this->container ->get(AuthorizationServer::class) ->willReturn($this->authServer->reveal()); @@ -86,7 +87,7 @@ public function testFactoryRaisesTypeErrorWhenResponseServiceProvidesResponseIns ->get(ResponseInterface::class) ->will([$this->response, 'reveal']); - $factory = new OAuth2MiddlewareFactory(); + $factory = new AuthorizationHandlerFactory(); $this->expectException(TypeError::class); $factory($this->container->reveal()); @@ -94,9 +95,6 @@ public function testFactoryRaisesTypeErrorWhenResponseServiceProvidesResponseIns public function testFactoryReturnsInstanceWhenAppropriateDependenciesArePresentInContainer() { - $this->container - ->has(AuthorizationServer::class) - ->willReturn(true); $this->container ->get(AuthorizationServer::class) ->willReturn($this->authServer->reveal()); @@ -106,8 +104,8 @@ public function testFactoryReturnsInstanceWhenAppropriateDependenciesArePresentI return $this->response->reveal(); }); - $factory = new OAuth2MiddlewareFactory(); + $factory = new AuthorizationHandlerFactory(); $middleware = $factory($this->container->reveal()); - $this->assertInstanceOf(OAuth2Middleware::class, $middleware); + $this->assertInstanceOf(AuthorizationHandler::class, $middleware); } } diff --git a/test/AuthorizationHandlerTest.php b/test/AuthorizationHandlerTest.php new file mode 100644 index 0000000..4e8987a --- /dev/null +++ b/test/AuthorizationHandlerTest.php @@ -0,0 +1,68 @@ +prophesize(AuthorizationServer::class); + $response = $this->prophesize(ResponseInterface::class); + $authRequest = $this->prophesize(AuthorizationRequest::class); + $request = $this->prophesize(ServerRequestInterface::class); + $expectedResponse = $response->reveal(); + + $request->getAttribute(AuthorizationRequest::class) + ->willReturn($authRequest->reveal()); + + $server->completeAuthorizationRequest($authRequest->reveal(), $expectedResponse) + ->shouldBeCalled() + ->willReturn($expectedResponse); + + $subject = new AuthorizationHandler($server->reveal(), function () use ($expectedResponse): ResponseInterface { + return $expectedResponse; + }); + + self::assertSame($expectedResponse, $subject->handle($request->reveal())); + } + + public function testInvalidResponseFactoryThrowsTypeError() + { + $server = $this->prophesize(AuthorizationServer::class); + $authRequest = $this->prophesize(AuthorizationRequest::class); + $request = $this->prophesize(ServerRequestInterface::class); + + $request->getAttribute(AuthorizationRequest::class) + ->willReturn($authRequest->reveal()); + + $server->completeAuthorizationRequest(Argument::any()) + ->shouldNotBeCalled(); + + $subject = new AuthorizationHandler($server->reveal(), function () { + return new stdClass(); + }); + + $this->expectException(TypeError::class); + $subject->handle($request->reveal()); + } +} diff --git a/test/AuthorizationMiddlewareFactoryTest.php b/test/AuthorizationMiddlewareFactoryTest.php new file mode 100644 index 0000000..32c536e --- /dev/null +++ b/test/AuthorizationMiddlewareFactoryTest.php @@ -0,0 +1,111 @@ +container = $this->prophesize(ContainerInterface::class); + $this->authServer = $this->prophesize(AuthorizationServer::class); + $this->response = $this->prophesize(ResponseInterface::class); + } + + public function testConstructor() + { + $factory = new AuthorizationMiddlewareFactory(); + $this->assertInstanceOf(AuthorizationMiddlewareFactory::class, $factory); + } + + public function testRaisesTypeErrorForInvalidAuthorizationServer() + { + $this->container + ->get(AuthorizationServer::class) + ->willReturn(new stdClass()); + $this->container + ->get(ResponseInterface::class) + ->willReturn(function () { + }); + + $factory = new AuthorizationMiddlewareFactory(); + + $this->expectException(TypeError::class); + $factory($this->container->reveal()); + } + + public function testFactoryRaisesTypeErrorForNonCallableResponseFactory() + { + $this->container + ->get(AuthorizationServer::class) + ->willReturn($this->authServer->reveal()); + $this->container + ->get(ResponseInterface::class) + ->willReturn(new stdClass()); + + $factory = new AuthorizationMiddlewareFactory(); + + $this->expectException(TypeError::class); + $factory($this->container->reveal()); + } + + public function testFactoryRaisesTypeErrorWhenResponseServiceProvidesResponseInstance() + { + $this->container + ->get(AuthorizationServer::class) + ->willReturn($this->authServer->reveal()); + $this->container + ->get(ResponseInterface::class) + ->will([$this->response, 'reveal']); + + $factory = new AuthorizationMiddlewareFactory(); + + $this->expectException(TypeError::class); + $factory($this->container->reveal()); + } + + public function testFactoryReturnsInstanceWhenAppropriateDependenciesArePresentInContainer() + { + $this->container + ->get(AuthorizationServer::class) + ->willReturn($this->authServer->reveal()); + $this->container + ->get(ResponseInterface::class) + ->willReturn(function () { + return $this->response->reveal(); + }); + + $factory = new AuthorizationMiddlewareFactory(); + $middleware = $factory($this->container->reveal()); + $this->assertInstanceOf(AuthorizationMiddleware::class, $middleware); + } +} diff --git a/test/AuthorizationMiddlewareTest.php b/test/AuthorizationMiddlewareTest.php new file mode 100644 index 0000000..d5d0eb7 --- /dev/null +++ b/test/AuthorizationMiddlewareTest.php @@ -0,0 +1,171 @@ +authServer = $this->prophesize(AuthorizationServer::class); + $this->response = $this->prophesize(ResponseInterface::class); + $this->serverRequest = $this->prophesize(ServerRequestInterface::class); + $this->authRequest = $this->prophesize(AuthorizationRequest::class); + $this->handler = $this->prophesize(RequestHandlerInterface::class); + $this->responseFactory = function () { + return $this->response->reveal(); + }; + } + + public function testConstructor() + { + $middleware = new AuthorizationMiddleware( + $this->authServer->reveal(), + $this->responseFactory + ); + + $this->assertInstanceOf(AuthorizationMiddleware::class, $middleware); + $this->assertInstanceOf(MiddlewareInterface::class, $middleware); + } + + public function testProcess() + { + $this->authRequest + ->setUser(Argument::any()) + ->shouldNotBeCalled(); // Ths middleware must not provide a user entity + $this->authRequest + ->setAuthorizationApproved(false) // Expect approval to be set to false only + ->willReturn(null); + + // Mock a valid authorization request + $this->authServer + ->validateAuthorizationRequest($this->serverRequest->reveal()) + ->willReturn($this->authRequest->reveal()); + + // Mock a instance immutability when the authorization request + // is populated + $newRequest = $this->prophesize(ServerRequestInterface::class); + $this->serverRequest + ->withAttribute(AuthorizationRequest::class, $this->authRequest->reveal()) + ->willReturn($newRequest->reveal()); + + // Expect the handler to be called with the new modified request, + // that contains the auth request attribute + $handlerResponse = $this->prophesize(ResponseInterface::class)->reveal(); + $this->handler + ->handle($newRequest->reveal()) + ->willReturn($handlerResponse); + + + $middleware = new AuthorizationMiddleware( + $this->authServer->reveal(), + $this->responseFactory + ); + $response = $middleware->process( + $this->serverRequest->reveal(), + $this->handler->reveal() + ); + + $this->assertSame($handlerResponse, $response); + } + + public function testAuthorizationRequestRaisingOAuthServerExceptionGeneratesResponseFromException() + { + $response = $this->prophesize(ResponseInterface::class); + $oauthServerException = $this->prophesize(OAuthServerException::class); + $oauthServerException + ->generateHttpResponse(Argument::type(ResponseInterface::class)) + ->willReturn($response->reveal()); + + $this->authServer + ->validateAuthorizationRequest($this->serverRequest->reveal()) + ->willThrow($oauthServerException->reveal()); + + $middleware = new AuthorizationMiddleware( + $this->authServer->reveal(), + $this->responseFactory + ); + + $result = $middleware->process( + $this->serverRequest->reveal(), + $this->handler->reveal() + ); + + $this->assertSame($response->reveal(), $result); + } + + public function testAuthorizationRequestRaisingUnknownExceptionGeneratesResponseFromException() + { + $body = $this->prophesize(StreamInterface::class); + $body + ->write(Argument::containingString('oauth2 server error')) + ->shouldBeCalled(); + + $this->response->getBody()->willReturn($body->reveal())->shouldBeCalled(); + $this->response + ->withHeader(Argument::type('string'), Argument::type('string')) + ->willReturn($this->response->reveal()) + ->shouldBeCalled(); + $this->response + ->withStatus(500) + ->willReturn($this->response->reveal()) + ->shouldBeCalled(); + + $exception = new RuntimeException('oauth2 server error'); + + $this->authServer + ->validateAuthorizationRequest($this->serverRequest->reveal()) + ->willThrow($exception); + + $middleware = new AuthorizationMiddleware( + $this->authServer->reveal(), + $this->responseFactory + ); + + $response = $middleware->process( + $this->serverRequest->reveal(), + $this->handler->reveal() + ); + + $this->assertSame($this->response->reveal(), $response); + } +} diff --git a/test/OAuth2MiddlewareTest.php b/test/OAuth2MiddlewareTest.php deleted file mode 100644 index b84a213..0000000 --- a/test/OAuth2MiddlewareTest.php +++ /dev/null @@ -1,282 +0,0 @@ -authServer = $this->prophesize(AuthorizationServer::class); - $this->response = $this->prophesize(ResponseInterface::class); - $this->serverRequest = $this->prophesize(ServerRequestInterface::class); - $this->authRequest = $this->prophesize(AuthorizationRequest::class); - $this->handler = $this->prophesize(RequestHandlerInterface::class); - $this->responseFactory = function () { - return $this->response->reveal(); - }; - } - - public function testConstructor() - { - $middleware = new OAuth2Middleware( - $this->authServer->reveal(), - $this->responseFactory - ); - $this->assertInstanceOf(OAuth2Middleware::class, $middleware); - $this->assertInstanceOf(MiddlewareInterface::class, $middleware); - } - - public function testProcessWithGet() - { - $this->authRequest - ->setUser(Argument::any()) - ->willReturn(null); - $this->authRequest - ->setAuthorizationApproved(true) - ->willReturn(null); - - $this->serverRequest - ->getMethod() - ->willReturn('GET'); - - $this->authServer - ->completeAuthorizationRequest( - $this->authRequest->reveal(), - $this->response->reveal() - ) - ->willReturn($this->response->reveal()); - $this->authServer - ->validateAuthorizationRequest($this->serverRequest->reveal()) - ->willReturn($this->authRequest); - - $middleware = new OAuth2Middleware( - $this->authServer->reveal(), - $this->responseFactory - ); - $response = $middleware->process( - $this->serverRequest->reveal(), - $this->handler->reveal() - ); - $this->assertInstanceOf(ResponseInterface::class, $response); - } - - public function testProcessWithPost() - { - $this->serverRequest->getMethod() - ->willReturn('POST'); - - $this->authServer - ->respondToAccessTokenRequest( - $this->serverRequest->reveal(), - $this->response->reveal() - ) - ->willReturn($this->response->reveal()); - - $middleware = new OAuth2Middleware( - $this->authServer->reveal(), - $this->responseFactory - ); - $response = $middleware->process( - $this->serverRequest->reveal(), - $this->handler->reveal() - ); - $this->assertInstanceOf(ResponseInterface::class, $response); - } - - public function testAuthorizationRequestRaisingOAuthServerExceptionGeneratesResponseFromException() - { - $response = $this->prophesize(ResponseInterface::class); - $oauthServerException = $this->prophesize(OAuthServerException::class); - $oauthServerException - ->generateHttpResponse(Argument::type(ResponseInterface::class)) - ->will([$response, 'reveal']); - - $this->authServer - ->validateAuthorizationRequest( - Argument::that([$this->serverRequest, 'reveal']) - ) - ->willThrow($oauthServerException->reveal()); - - $middleware = new OAuth2Middleware( - $this->authServer->reveal(), - $this->responseFactory - ); - - $this->serverRequest->getMethod()->willReturn('GET'); - - $result = $middleware->process( - $this->serverRequest->reveal(), - $this->handler->reveal() - ); - - $this->assertSame($response->reveal(), $result); - } - - public function testAuthorizationRequestRaisingUnknownExceptionGeneratesResponseFromException() - { - $body = $this->prophesize(StreamInterface::class); - $body - ->write(Argument::containingString('oauth2 server error')) - ->shouldBeCalled(); - - $this->response->getBody()->will([$body, 'reveal'])->shouldBeCalled(); - $this->response - ->withHeader(Argument::type('string'), Argument::type('string')) - ->will([$this->response, 'reveal']) - ->shouldBeCalled(); - $this->response - ->withStatus(500) - ->will([$this->response, 'reveal']) - ->shouldBeCalled(); - - $exception = new RuntimeException('oauth2 server error'); - - $this->authServer - ->validateAuthorizationRequest( - Argument::that([$this->serverRequest, 'reveal']) - ) - ->willThrow($exception); - - $middleware = new OAuth2Middleware( - $this->authServer->reveal(), - $this->responseFactory - ); - - $this->serverRequest->getMethod()->willReturn('GET'); - - $response = $middleware->process( - $this->serverRequest->reveal(), - $this->handler->reveal() - ); - - $this->assertSame($this->response->reveal(), $response); - } - - public function testReturns501ResponseForInvalidMethods() - { - $this->serverRequest->getMethod()->willReturn('UNKNOWN'); - $this->response->withStatus(501)->will([$this->response, 'reveal']); - - $middleware = new OAuth2Middleware( - $this->authServer->reveal(), - $this->responseFactory - ); - - $response = $middleware->process( - $this->serverRequest->reveal(), - $this->handler->reveal() - ); - - $this->assertSame($this->response->reveal(), $response); - } - - public function testPostRequestResultingInOAuthServerExceptionUsesExceptionToGenerateResponse() - { - $this->serverRequest->getMethod()->willReturn('POST'); - - $exception = $this->prophesize(OAuthServerException::class); - $exception - ->generateHttpResponse(Argument::that([$this->response, 'reveal'])) - ->will([$this->response, 'reveal']); - - $this->authServer - ->respondToAccessTokenRequest( - Argument::that([$this->serverRequest, 'reveal']), - Argument::that([$this->response, 'reveal']) - ) - ->willThrow($exception->reveal()); - - $middleware = new OAuth2Middleware( - $this->authServer->reveal(), - $this->responseFactory - ); - - $response = $middleware->process( - $this->serverRequest->reveal(), - $this->handler->reveal() - ); - - $this->assertSame($this->response->reveal(), $response); - } - - public function testPostRequestResultingInGenericExceptionCastsExceptionToOauthServerExceptionToGenerateResponse() - { - $this->serverRequest->getMethod()->willReturn('POST'); - - $exception = new RuntimeException('runtime-exception', 500); - - $body = $this->prophesize(StreamInterface::class); - $body->write(Argument::containingString('runtime-exception'))->shouldBeCalled(); - - $this->response - ->withHeader('Content-type', 'application/json') - ->will([$this->response, 'reveal']); - - $this->response - ->getBody() - ->will([$body, 'reveal']); - - $this->response - ->withStatus(500) - ->will([$this->response, 'reveal']); - - $this->authServer - ->respondToAccessTokenRequest( - Argument::that([$this->serverRequest, 'reveal']), - Argument::that([$this->response, 'reveal']) - ) - ->willThrow($exception); - - $middleware = new OAuth2Middleware( - $this->authServer->reveal(), - $this->responseFactory - ); - - $response = $middleware->process( - $this->serverRequest->reveal(), - $this->handler->reveal() - ); - - $this->assertSame($this->response->reveal(), $response); - } -} diff --git a/test/Pdo/OAuth2PdoMiddlewareTest.php b/test/Pdo/OAuth2PdoMiddlewareTest.php index dfec773..e1858f4 100644 --- a/test/Pdo/OAuth2PdoMiddlewareTest.php +++ b/test/Pdo/OAuth2PdoMiddlewareTest.php @@ -17,14 +17,19 @@ use League\OAuth2\Server\Grant\ImplicitGrant; use League\OAuth2\Server\Grant\PasswordGrant; use League\OAuth2\Server\Grant\RefreshTokenGrant; +use League\OAuth2\Server\RequestTypes\AuthorizationRequest; use PDO; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Zend\Diactoros\Response; use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Stream; -use Zend\Expressive\Authentication\OAuth2\OAuth2Middleware; +use Zend\Expressive\Authentication\OAuth2\AuthorizationHandler; +use Zend\Expressive\Authentication\OAuth2\AuthorizationMiddleware; +use Zend\Expressive\Authentication\OAuth2\Entity\UserEntity; use Zend\Expressive\Authentication\OAuth2\Repository\Pdo\AccessTokenRepository; use Zend\Expressive\Authentication\OAuth2\Repository\Pdo\AuthCodeRepository; use Zend\Expressive\Authentication\OAuth2\Repository\Pdo\ClientRepository; @@ -32,7 +37,8 @@ use Zend\Expressive\Authentication\OAuth2\Repository\Pdo\RefreshTokenRepository; use Zend\Expressive\Authentication\OAuth2\Repository\Pdo\ScopeRepository; use Zend\Expressive\Authentication\OAuth2\Repository\Pdo\UserRepository; - +use Zend\Expressive\Authentication\OAuth2\TokenEndpointHandler; +use function assert; use function bin2hex; use function explode; use function file_exists; @@ -45,6 +51,11 @@ use function strtolower; use function unlink; +/** + * Integration test for the authorization flows with PDO + * + * @coversNothing + */ class OAuth2PdoMiddlewareTest extends TestCase { const DB_FILE = __DIR__ . '/TestAsset/test_oauth2.sq3'; @@ -59,7 +70,7 @@ class OAuth2PdoMiddlewareTest extends TestCase /** @var AuthCodeRepository */ private $authCodeRepository; - /** @var AuthServer */ + /** @var AuthorizationServer */ private $authServer; /** @var ClientRepository */ @@ -139,15 +150,6 @@ public function setUp() }; } - public function testConstructor() - { - $authMiddleware = new OAuth2Middleware( - $this->authServer, - $this->responseFactory - ); - $this->assertInstanceOf(OAuth2Middleware::class, $authMiddleware); - } - /** * Test the Client Credential Grant * @@ -176,12 +178,12 @@ public function testProcessClientCredentialGrant() [ 'Content-Type' => 'application/x-www-form-urlencoded' ] ); - $authMiddleware = new OAuth2Middleware( + $handler = new TokenEndpointHandler( $this->authServer, $this->responseFactory ); - $response = $authMiddleware->process($request, $this->handler->reveal()); + $response = $handler->handle($request); $this->assertEquals(200, $response->getStatusCode()); $content = json_decode((string) $response->getBody()); @@ -224,12 +226,12 @@ public function testProcessPasswordGrant() [ 'Content-Type' => 'application/x-www-form-urlencoded' ] ); - $authMiddleware = new OAuth2Middleware( + $handler = new TokenEndpointHandler( $this->authServer, $this->responseFactory ); - $response = $authMiddleware->process($request, $this->handler->reveal()); + $response = $handler->handle($request); $this->assertEquals(200, $response->getStatusCode()); $content = json_decode((string) $response->getBody()); @@ -240,7 +242,7 @@ public function testProcessPasswordGrant() } /** - * Test the Authorization Code Grant (Part One) + * Test the Authorization Code Grant flow (Part One) * * @see https://oauth2.thephpleague.com/authorization-server/auth-code-grant/ */ @@ -276,12 +278,12 @@ public function testProcessGetAuthorizationCode() $params ); - $authMiddleware = new OAuth2Middleware( - $this->authServer, - $this->responseFactory - ); + // mocks the authorization endpoint pipe + $authMiddleware = new AuthorizationMiddleware($this->authServer, $this->responseFactory); + $authHandler = new AuthorizationHandler($this->authServer, $this->responseFactory); + $consumerHandler = $this->buildConsumerAuthMiddleware($authHandler); - $response = $authMiddleware->process($request, $this->handler->reveal()); + $response = $authMiddleware->process($request, $consumerHandler); $this->assertEquals(302, $response->getStatusCode()); $this->assertTrue($response->hasHeader('Location')); @@ -332,12 +334,12 @@ public function testProcessFromAuthorizationCode(string $code) [ 'Content-Type' => 'application/x-www-form-urlencoded' ] ); - $authMiddleware = new OAuth2Middleware( + $handler = new TokenEndpointHandler( $this->authServer, $this->responseFactory ); - $response = $authMiddleware->process($request, $this->handler->reveal()); + $response = $handler->handle($request); $this->assertEquals(200, $response->getStatusCode()); $content = json_decode((string) $response->getBody()); @@ -379,12 +381,11 @@ public function testProcessImplicitGrant() $params ); - $authMiddleware = new OAuth2Middleware( - $this->authServer, - $this->responseFactory - ); + $authMiddleware = new AuthorizationMiddleware($this->authServer, $this->responseFactory); + $authHandler = new AuthorizationHandler($this->authServer, $this->responseFactory); + $consumerHandler = $this->buildConsumerAuthMiddleware($authHandler); - $response = $authMiddleware->process($request, $this->handler->reveal()); + $response = $authMiddleware->process($request, $consumerHandler); $this->assertEquals(302, $response->getStatusCode()); $this->assertTrue($response->hasHeader('Location')); @@ -432,12 +433,12 @@ public function testProcessRefreshTokenGrant(string $refreshToken) [ 'Content-Type' => 'application/x-www-form-urlencoded' ] ); - $authMiddleware = new OAuth2Middleware( + $handler = new TokenEndpointHandler( $this->authServer, $this->responseFactory ); - $response = $authMiddleware->process($request, $this->handler->reveal()); + $response = $handler->handle($request); $this->assertEquals(200, $response->getStatusCode()); $content = json_decode((string) $response->getBody()); @@ -447,6 +448,35 @@ public function testProcessRefreshTokenGrant(string $refreshToken) $this->assertNotEmpty($content->refresh_token); } + private function buildConsumerAuthMiddleware(AuthorizationHandler $authHandler) + { + return new class($authHandler) implements RequestHandlerInterface + { + /** + * @var AuthorizationHandler + */ + private $handler; + + public function __construct(AuthorizationHandler $handler) + { + $this->handler = $handler; + } + + public function handle( + ServerRequestInterface $request + ): ResponseInterface { + $authRequest = $request->getAttribute(AuthorizationRequest::class); + assert($authRequest instanceof AuthorizationRequest); + $authRequest->setUser(new UserEntity('test')); + $authRequest->setAuthorizationApproved(true); + + return $this->handler->handle( + $request->withAttribute(AuthorizationRequest::class, $authRequest) + ); + } + }; + } + /** * Build a ServerRequest object */ diff --git a/test/TokenEndpointHandlerFactoryTest.php b/test/TokenEndpointHandlerFactoryTest.php new file mode 100644 index 0000000..f706b21 --- /dev/null +++ b/test/TokenEndpointHandlerFactoryTest.php @@ -0,0 +1,75 @@ +subject = new TokenEndpointHandlerFactory(); + parent::setUp(); + } + + public function testEmptyContainerThrowsTypeError() + { + $container = $this->prophesize(ContainerInterface::class); + + $this->expectException(TypeError::class); + ($this->subject)($container); + } + + public function testCreatesTokenEndpointHandler() + { + $server = $this->prophesize(AuthorizationServer::class); + $responseFactory = function () { + }; + $container = $this->prophesize(ContainerInterface::class); + + $container->get(AuthorizationServer::class) + ->willReturn($server->reveal()); + $container->get(ResponseInterface::class) + ->willReturn($responseFactory); + + self::assertInstanceOf(TokenEndpointHandler::class, ($this->subject)($container->reveal())); + } + + public function testDirectResponseInstanceFromContainerThrowsTypeError() + { + $server = $this->prophesize(AuthorizationServer::class); + $container = $this->prophesize(ContainerInterface::class); + + $container->get(AuthorizationServer::class) + ->willReturn($server->reveal()); + $container->get(ResponseInterface::class) + ->willReturn($this->prophesize(ResponseInterface::class)->reveal()); + + $this->expectException(TypeError::class); + ($this->subject)($container->reveal()); + } +} diff --git a/test/TokenEndpointHandlerTest.php b/test/TokenEndpointHandlerTest.php new file mode 100644 index 0000000..99833e9 --- /dev/null +++ b/test/TokenEndpointHandlerTest.php @@ -0,0 +1,82 @@ +prophesize(ResponseInterface::class)->reveal(); + }; + } + + public function testHandleUsesAuthorizationServer() + { + $server = $this->prophesize(AuthorizationServer::class); + $request = $this->prophesize(ServerRequestInterface::class); + $response = $this->prophesize(ResponseInterface::class); + $expectedResponse = $response->reveal(); + + $server->respondToAccessTokenRequest($request->reveal(), $expectedResponse) + ->shouldBeCalled() + ->willReturn($expectedResponse); + + $subject = new TokenEndpointHandler($server->reveal(), $this->createResponseFactory($expectedResponse)); + self::assertSame($expectedResponse, $subject->handle($request->reveal())); + } + + public function testOAuthExceptionProducesResult() + { + $server = $this->prophesize(AuthorizationServer::class); + $request = $this->prophesize(ServerRequestInterface::class); + $response = $this->prophesize(ResponseInterface::class); + $exception = $this->prophesize(OAuthServerException::class); + $expectedResponse = $response->reveal(); + + $server->respondToAccessTokenRequest(Argument::cetera()) + ->willThrow($exception->reveal()); + + $exception->generateHttpResponse($expectedResponse, Argument::cetera()) + ->shouldBeCalled() + ->willReturn($expectedResponse); + + $subject = new TokenEndpointHandler($server->reveal(), $this->createResponseFactory($expectedResponse)); + self::assertSame($expectedResponse, $subject->handle($request->reveal())); + } + + public function testGenericExceptionsFallsThrough() + { + $server = $this->prophesize(AuthorizationServer::class); + $request = $this->prophesize(ServerRequestInterface::class); + $exception = new RuntimeException(); + + $server->respondToAccessTokenRequest(Argument::cetera()) + ->willThrow($exception); + + $subject = new TokenEndpointHandler($server->reveal(), $this->createResponseFactory()); + + $this->expectException(RuntimeException::class); + $subject->handle($request->reveal()); + } +}