From 5c015a3bff63d6764c6a09e452cf83de9c05869d Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 24 Oct 2024 12:29:31 +0300 Subject: [PATCH 1/6] Abstract post body and get parameters parsing --- src/Controller/Traits/ErrorTrait.php | 10 +++--- src/Controller/Traits/RequestTrait.php | 28 ++++++++++++--- src/Lib/RequestUtilities.php | 49 ++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 src/Lib/RequestUtilities.php diff --git a/src/Controller/Traits/ErrorTrait.php b/src/Controller/Traits/ErrorTrait.php index 53b4bb5..c47806a 100644 --- a/src/Controller/Traits/ErrorTrait.php +++ b/src/Controller/Traits/ErrorTrait.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\authoauth2\Controller\Traits; use Symfony\Component\HttpFoundation\Request; +use SimpleSAML\Module\authoauth2\Lib\RequestUtilities; trait ErrorTrait { @@ -15,16 +16,17 @@ trait ErrorTrait */ public function parseError(Request $request): array { + $requestParams = RequestUtilities::getRequestParams($request); // Do not throw if errors are suppressed by @ operator // error_reporting() value for suppressed errors changed in PHP 8.0.0 $error = ''; $error_description = ''; - if ($request->query->has('error')) { - $error = (string)$request->query->get('error'); + if (isset($requestParams['error'])) { + $error = (string)$requestParams['error']; } - if ($request->query->has('error_description')) { - $error_description = (string)$request->query->get('error_description'); + if (isset($requestParams['error_description'])) { + $error_description = (string)$requestParams['error_description']; } return [ diff --git a/src/Controller/Traits/RequestTrait.php b/src/Controller/Traits/RequestTrait.php index 3ca5df6..1a859df 100644 --- a/src/Controller/Traits/RequestTrait.php +++ b/src/Controller/Traits/RequestTrait.php @@ -9,8 +9,9 @@ use SimpleSAML\Error\BadRequest; use SimpleSAML\Error\NoState; use SimpleSAML\Module\authoauth2\Auth\Source\OAuth2; -use SimpleSAML\Module\authoauth2\Codebooks\RoutesEnum; use SimpleSAML\Module\authoauth2\Codebooks\LegacyRoutesEnum; +use SimpleSAML\Module\authoauth2\Codebooks\RoutesEnum; +use SimpleSAML\Module\authoauth2\Lib\RequestUtilities; use SimpleSAML\Module\authoauth2\locators\SourceServiceLocator; use Symfony\Component\HttpFoundation\Request; @@ -36,6 +37,9 @@ trait RequestTrait */ protected string $expectedStateAuthId = OAuth2::AUTHID; + /** @var array|null */ + private ?array $requestParams; + /** * @param Request $request * @@ -43,11 +47,12 @@ trait RequestTrait */ public function stateIsValid(Request $request): bool { - if (!$request->query->has('state')) { + $requestParams = $this->parseRequestParamsSingleton($request); + if (!isset($requestParams['state'])) { return false; } /** @var ?string $stateId */ - $stateId = $request->query->get('state'); + $stateId = $requestParams['state']; if (empty($stateId)) { return false; } @@ -72,7 +77,8 @@ public function parseRequest(Request $request): void }; throw new BadRequest($message); } - $stateIdWithPrefix = (string)($request->query->get('state') ?? ''); + $requestParams = $this->parseRequestParamsSingleton($request); + $stateIdWithPrefix = (string)($requestParams['state'] ?? ''); $stateId = substr($stateIdWithPrefix, \strlen($this->expectedPrefix)); $this->state = $this->loadState($stateId, $this->expectedStageState); @@ -110,4 +116,18 @@ public function loadState(string $id, string $stage, bool $allowMissing = false) { return State::loadState($id, $stage, $allowMissing); } + + /** + * @param Request $request + * + * @return array + */ + public function parseRequestParamsSingleton(Request $request): array + { + if (!empty($this->requestParams)) { + return $this->requestParams; + } + + return RequestUtilities::getRequestParams($request); + } } diff --git a/src/Lib/RequestUtilities.php b/src/Lib/RequestUtilities.php new file mode 100644 index 0000000..c66b8c5 --- /dev/null +++ b/src/Lib/RequestUtilities.php @@ -0,0 +1,49 @@ +isMethod('GET')) { + foreach ($request->query->all() as $key => $value) { + yield $key => $value; + } + } elseif ($request->isMethod('POST')) { + foreach ($request->request->all() as $key => $value) { + yield $key => $value; + } + } else { + // If it is neither a GET or a POST then yield nothing + yield; + } + } + + /** + * @param Request $request + * + * @return array + */ + public static function getRequestParams(Request $request): array + { + $params = []; + if ($request->isMethod('GET')) { + $params = $request->query->all(); + } elseif ($request->isMethod('POST')) { + $params = $request->request->all(); + } + + return $params; + } +} From f43e4a046caff7370e2114c701c9e09ebda90bda Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Fri, 25 Oct 2024 17:22:09 +0300 Subject: [PATCH 2/6] Add tests for post+get parameters request handling --- docs/APPLE.md | 6 ++++- .../lib/Controller/Trait/RequestTraitTest.php | 22 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/APPLE.md b/docs/APPLE.md index 7246c80..ad38456 100644 --- a/docs/APPLE.md +++ b/docs/APPLE.md @@ -77,4 +77,8 @@ docker run --name ssp-apple-oidc \ Edit your `/etc/hosts` file to make `apple.test.idpproxy.illinois.edu` route to local host and then visit `https://apple.test.idpproxy.illinois.edu/simplesaml/module.php/core/authenticate.php?as=appleTest` to -initiate a login to Apple. Non-secret values such as keyId and teamId \ No newline at end of file +initiate a login to Apple. Non-secret values such as keyId and teamId + +# Documentation + +* (TN3107: Resolving Sign in with Apple response errors)[https://developer.apple.com/documentation/technotes/tn3107-resolving-sign-in-with-apple-response-errors] \ No newline at end of file diff --git a/tests/lib/Controller/Trait/RequestTraitTest.php b/tests/lib/Controller/Trait/RequestTraitTest.php index c0125ac..1a4b559 100644 --- a/tests/lib/Controller/Trait/RequestTraitTest.php +++ b/tests/lib/Controller/Trait/RequestTraitTest.php @@ -147,7 +147,6 @@ public function testParseRequestWithEmptyAuthSourceId(): void $this->controller->parseRequest($this->request); } - public function testParseRequestWithInvalidSource(): void { $this->request->query->set('state', OAuth2::STATE_PREFIX . '|valid_state_with_invalid_source'); @@ -166,4 +165,25 @@ public function testParseRequestWithInvalidSource(): void $this->controller->parseRequest($this->request); } + + public function testParsePostGetRequest(): void + { + $request = Request::create( + uri: 'https://localhost/auth/authorize', + method: 'POST', + parameters: [ 'error' => 'invalid_request'], + ); + + $expected = ['error' => 'invalid_request']; + + $this->assertEquals($expected, $this->parseRequestParamsSingleton($request)); + + $request = Request::create( + uri: 'https://localhost/auth/authorize', + method: 'GET', + parameters: [ 'error' => 'invalid_request'] + ); + + $this->assertEquals($expected, $this->parseRequestParamsSingleton($request)); + } } From 59467b353ff548927ba3d189d672a6dc4f138828 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 27 Oct 2024 11:44:46 +0200 Subject: [PATCH 3/6] Fix request parameters for actions. Refactored oidc and oauth2 controller tests. --- composer.json | 5 +- phpunit.xml | 4 - src/Controller/OIDCLogoutController.php | 16 +- src/Controller/Oauth2Controller.php | 9 +- src/Controller/Traits/RequestTrait.php | 24 +- src/Lib/RequestUtilities.php | 22 -- .../Controller/OIDCLogoutControllerTest.php | 194 ++++++----- tests/lib/Controller/Oauth2ControllerTest.php | 301 +++++++++--------- .../lib/Controller/Trait/RequestTraitTest.php | 6 +- 9 files changed, 291 insertions(+), 290 deletions(-) diff --git a/composer.json b/composer.json index 62ee260..30a4125 100644 --- a/composer.json +++ b/composer.json @@ -23,8 +23,7 @@ "simplesamlphp/simplesamlphp-test-framework": "^1.7", "phpunit/phpunit": "^10", "psalm/plugin-phpunit": "^0.19.0", - "squizlabs/php_codesniffer": "^3.7", - "dg/bypass-finals": "^1.8" + "squizlabs/php_codesniffer": "^3.7" }, "autoload": { "psr-4": { @@ -49,7 +48,7 @@ }, "scripts": { "validate": [ - "vendor/bin/phpunit --no-coverage", + "vendor/bin/phpunit --no-coverage --testdox", "vendor/bin/phpcs -p", "vendor/bin/psalm --no-cache" ], diff --git a/phpunit.xml b/phpunit.xml index 0a8f4ea..c60752c 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,10 +8,6 @@ displayDetailsOnTestsThatTriggerDeprecations="true" backupGlobals="false" > - - - - ./tests diff --git a/src/Controller/OIDCLogoutController.php b/src/Controller/OIDCLogoutController.php index 9c731cf..22b1f5d 100644 --- a/src/Controller/OIDCLogoutController.php +++ b/src/Controller/OIDCLogoutController.php @@ -12,6 +12,8 @@ use SimpleSAML\Logger; use SimpleSAML\Module\authoauth2\Auth\Source\OAuth2; use SimpleSAML\Module\authoauth2\Auth\Source\OpenIDConnect; +use SimpleSAML\Module\authoauth2\Codebooks\LegacyRoutesEnum; +use SimpleSAML\Module\authoauth2\Codebooks\RoutesEnum; use SimpleSAML\Module\authoauth2\Controller\Traits\RequestTrait; use SimpleSAML\Module\authoauth2\locators\SourceServiceLocator; use Symfony\Component\HttpFoundation\Request; @@ -55,9 +57,8 @@ public function __construct(Configuration $config = null) */ public function loggedout(Request $request): void { - Logger::debug('authoauth2: logout request=' . var_export($request->request->all(), true)); - $this->parseRequest($request); + Logger::debug('authoauth2: logout request=' . var_export($this->requestParams, true)); \assert(\is_array($this->state)); @@ -72,19 +73,22 @@ public function loggedout(Request $request): void */ public function logout(Request $request): void { - Logger::debug('authoauth2: logout request=' . var_export($request->request->all(), true)); + $this->parseRequestParamsSingleton($request); + Logger::debug('authoauth2: logout request=' . var_export($this->requestParams, true)); // Find the authentication source - if (!$request->query->has('authSource')) { + if (!isset($this->requestParams['authSource'])) { throw new BadRequest('No authsource in the request'); } - $sourceId = $request->query->get('authSource'); + $sourceId = $this->requestParams['authSource']; if (empty($sourceId) || !\is_string($sourceId)) { throw new BadRequest('Authsource ID invalid'); } + $logoutRoute = $this->config->getOptionalBoolean('useLegacyRoutes', false) ? + LegacyRoutesEnum::LegacyLogout->value : RoutesEnum::Logout->value; $this->getAuthSource($sourceId) ->logout([ 'oidc:localLogout' => true, - 'ReturnTo' => $this->config->getBasePath() . 'logout.php', + 'ReturnTo' => $this->config->getBasePath() . $logoutRoute, ]); } diff --git a/src/Controller/Oauth2Controller.php b/src/Controller/Oauth2Controller.php index 359c0df..701d948 100644 --- a/src/Controller/Oauth2Controller.php +++ b/src/Controller/Oauth2Controller.php @@ -51,9 +51,8 @@ public function __construct() */ public function linkback(Request $request): void { - Logger::debug('authoauth2: linkback request=' . var_export($request->query->all(), true)); - $this->parseRequest($request); + Logger::debug('authoauth2: linkback request=' . var_export($this->requestParams, true)); // Required for psalm \assert($this->source instanceof OAuth2); @@ -61,12 +60,14 @@ public function linkback(Request $request): void \assert(\is_string($this->sourceId)); // Handle Identify Provider error - if (!$request->query->has('code') || empty($request->query->get('code'))) { + if (empty($this->requestParams['code'])) { $this->handleError($this->source, $this->state, $request); + // Used to facilitate testing + return; } try { - $this->source->finalStep($this->state, (string)$request->query->get('code')); + $this->source->finalStep($this->state, (string)$this->requestParams['code']); } catch (IdentityProviderException $e) { // phpcs:ignore Generic.Files.LineLength.TooLong Logger::error("authoauth2: error in '$this->sourceId' msg '{$e->getMessage()}' body '" . var_export($e->getResponseBody(), true) . "'"); diff --git a/src/Controller/Traits/RequestTrait.php b/src/Controller/Traits/RequestTrait.php index 1a859df..2fd5711 100644 --- a/src/Controller/Traits/RequestTrait.php +++ b/src/Controller/Traits/RequestTrait.php @@ -37,8 +37,8 @@ trait RequestTrait */ protected string $expectedStateAuthId = OAuth2::AUTHID; - /** @var array|null */ - private ?array $requestParams; + /** @var array */ + protected array $requestParams = []; /** * @param Request $request @@ -47,12 +47,13 @@ trait RequestTrait */ public function stateIsValid(Request $request): bool { - $requestParams = $this->parseRequestParamsSingleton($request); - if (!isset($requestParams['state'])) { + // Parse the request parameters + $this->parseRequestParamsSingleton($request); + if (!isset($this->requestParams['state'])) { return false; } /** @var ?string $stateId */ - $stateId = $requestParams['state']; + $stateId = $this->requestParams['state']; if (empty($stateId)) { return false; } @@ -77,8 +78,7 @@ public function parseRequest(Request $request): void }; throw new BadRequest($message); } - $requestParams = $this->parseRequestParamsSingleton($request); - $stateIdWithPrefix = (string)($requestParams['state'] ?? ''); + $stateIdWithPrefix = (string)($this->requestParams['state'] ?? ''); $stateId = substr($stateIdWithPrefix, \strlen($this->expectedPrefix)); $this->state = $this->loadState($stateId, $this->expectedStageState); @@ -119,15 +119,11 @@ public function loadState(string $id, string $stage, bool $allowMissing = false) /** * @param Request $request - * - * @return array */ - public function parseRequestParamsSingleton(Request $request): array + public function parseRequestParamsSingleton(Request $request): void { - if (!empty($this->requestParams)) { - return $this->requestParams; + if (empty($this->requestParams)) { + $this->requestParams = RequestUtilities::getRequestParams($request); } - - return RequestUtilities::getRequestParams($request); } } diff --git a/src/Lib/RequestUtilities.php b/src/Lib/RequestUtilities.php index c66b8c5..66bf56f 100644 --- a/src/Lib/RequestUtilities.php +++ b/src/Lib/RequestUtilities.php @@ -4,32 +4,10 @@ namespace SimpleSAML\Module\authoauth2\Lib; -use Generator; use Symfony\Component\HttpFoundation\Request; class RequestUtilities { - /** - * @param Request $request - * - * @return Generator - */ - public static function parseRequestGenerator(Request $request): Generator - { - if ($request->isMethod('GET')) { - foreach ($request->query->all() as $key => $value) { - yield $key => $value; - } - } elseif ($request->isMethod('POST')) { - foreach ($request->request->all() as $key => $value) { - yield $key => $value; - } - } else { - // If it is neither a GET or a POST then yield nothing - yield; - } - } - /** * @param Request $request * diff --git a/tests/lib/Controller/OIDCLogoutControllerTest.php b/tests/lib/Controller/OIDCLogoutControllerTest.php index f8a90a7..e983549 100644 --- a/tests/lib/Controller/OIDCLogoutControllerTest.php +++ b/tests/lib/Controller/OIDCLogoutControllerTest.php @@ -5,17 +5,17 @@ namespace SimpleSAML\Module\authoauth2\Tests\Controller; use DG\BypassFinals; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; -use SimpleSAML\Auth\Source; -use SimpleSAML\Configuration; +use SimpleSAML\Auth\Simple; use SimpleSAML\Error\BadRequest; use SimpleSAML\Module\authoauth2\Auth\Source\OAuth2; use SimpleSAML\Module\authoauth2\Auth\Source\OpenIDConnect; +use SimpleSAML\Module\authoauth2\Codebooks\RoutesEnum; use SimpleSAML\Module\authoauth2\Controller\OIDCLogoutController; use SimpleSAML\Module\authoauth2\Controller\Traits\RequestTrait; use SimpleSAML\Module\authoauth2\locators\SourceService; -use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\Request; // Unless we declare the class here, it is not recognized by phpcs @@ -23,21 +23,6 @@ class OIDCLogoutControllerMock extends OIDCLogoutController { use RequestTrait; - public function setSource(Source $source): void - { - $this->source = $source; - } - - public function setState(array $state): void - { - $this->state = $state; - } - - public function setSourceId(string $sourceId): void - { - $this->sourceId = $sourceId; - } - public function getExpectedStageState(): string { return $this->expectedStageState; @@ -54,37 +39,25 @@ class OIDCLogoutControllerTest extends TestCase { /** @var OIDCLogoutControllerMock */ private $controller; - /** @var Request */ - private $requestMock; - /** @var Configuration */ - private $configMock; - private array $stateMock; /** @var SourceService */ private $sourceServiceMock; + /** @var \PHPUnit\Framework\MockObject\MockObject|(OAuth2&\PHPUnit\Framework\MockObject\MockObject) */ + private $oauth2Mock; + /** @var \PHPUnit\Framework\MockObject\MockObject|(Simple&\PHPUnit\Framework\MockObject\MockObject) */ + private $simpleMock; + private array $stateMock; + private array $parametersMock; /** * @throws Exception */ protected function setUp(): void { - BypassFinals::enable(bypassReadOnly: false); - - $this->configMock = $this->createMock(Configuration::class); + $this->parametersMock = ['state' => OAuth2::STATE_PREFIX . '-statefoo']; + $this->stateMock = [OAuth2::AUTHID => 'testSourceId']; - // Initial state setup - $this->stateMock = ['state' => 'testState']; - - $this->controller = $this->getMockBuilder(OIDCLogoutControllerMock::class) - ->setConstructorArgs([$this->configMock]) - ->onlyMethods(['parseRequest', 'getSourceService', 'getAuthSource']) - ->getMock(); - - $this->requestMock = $this->getMockBuilder(Request::class)->getMock(); - - $this->controller->method('getSourceService') - ->willReturn($this->createMock(SourceService::class)); - - $this->sourceServiceMock = $this->controller->getSourceService(); + // Create the mock controller + $this->createControllerMock(['getSourceService', 'loadState', 'getAuthSource']); } public function testExpectedConstVariables(): void @@ -93,11 +66,26 @@ public function testExpectedConstVariables(): void $this->assertEquals(OAuth2::STATE_PREFIX . '-', $this->controller->getExpectedPrefix()); } - public function testLoggedOutSuccess(): void + public static function requestMethod(): array { - $this->controller->setState($this->stateMock); + return [ + 'GET' => ['GET'], + 'POST' => ['POST'], + ]; + } - $this->requestMock->request = $this->createRequestMock([]); + #[DataProvider('requestMethod')] + public function testLoggedOutSuccess(string $requestMethod): void + { + $parameters = [ + ...$this->parametersMock, + ]; + + $request = Request::create( + uri: 'https://localhost/auth/authorize', + method: $requestMethod, + parameters: $parameters, + ); /** @psalm-suppress UndefinedMethod,MixedMethodCall */ $this->sourceServiceMock @@ -105,68 +93,112 @@ public function testLoggedOutSuccess(): void ->method('completeLogout') ->with($this->stateMock); - $this->controller->loggedout($this->requestMock); - - - // Assertions to verify behavior + $this->controller->loggedout($request); } - public function testLogoutWithoutAuthSourceThrowsBadRequest(): void + #[DataProvider('requestMethod')] + public function testLogoutWithoutAuthSourceThrowsBadRequest(string $requestMethod): void { - $this->requestMock->query = $this->createQueryMock([]); - $this->requestMock->request = $this->createRequestMock([]); + $parameters = [ + ...$this->parametersMock, + ]; + + $request = Request::create( + uri: 'https://localhost/auth/authorize', + method: $requestMethod, + parameters: $parameters, + ); $this->expectException(BadRequest::class); $this->expectExceptionMessage('No authsource in the request'); - $this->controller->logout($this->requestMock); + $this->controller->logout($request); } - public function testLogoutWithInvalidAuthSourceThrowsBadRequest(): void + #[DataProvider('requestMethod')] + public function testLogoutWithInvalidAuthSourceThrowsBadRequest(string $requestMethod): void { - $this->requestMock->query = $this->createQueryMock(['authSource' => '']); - $this->requestMock->request = $this->createRequestMock([]); + $parameters = [ + 'authSource' => ['INVALID SOURCE ID'], + ...$this->parametersMock, + ]; + + $request = Request::create( + uri: 'https://localhost/auth/authorize', + method: $requestMethod, + parameters: $parameters, + ); $this->expectException(BadRequest::class); $this->expectExceptionMessage('Authsource ID invalid'); - $this->controller->logout($this->requestMock); + $this->controller->logout($request); } - // Mock helper function - private function createQueryMock(array $params): InputBag + #[DataProvider('requestMethod')] + public function testSuccessfullLogout(string $requestMethod): void { - $queryMock = $this->getMockBuilder(InputBag::class)->getMock(); - $queryMock->method('has')->willReturnCallback( - function (string $key) use ($params) { - return array_key_exists($key, $params); - } + $parameters = [ + 'authSource' => 'authsourceid', + ...$this->parametersMock, + ]; + + $request = Request::create( + uri: 'https://localhost/auth/authorize', + method: $requestMethod, + parameters: $parameters, ); - $queryMock->method('get')->willReturnCallback( - function (?string $key) use ($params) { - return $params[$key] ?? null; - } - ); - return $queryMock; + $logoutConfig = [ + 'oidc:localLogout' => true, + 'ReturnTo' => '/' . RoutesEnum::Logout->value + ]; + + /** @psalm-suppress UndefinedMethod,MixedMethodCall */ + $this->simpleMock + ->expects($this->once()) + ->method('logout') + ->with($logoutConfig); + + $this->controller->logout($request); } // Mock helper function - private function createRequestMock(array $params): InputBag + private function createControllerMock(array $methods): void { - $queryMock = $this->getMockBuilder(InputBag::class)->getMock(); + $this->oauth2Mock = $this->getMockBuilder(OAuth2::class) + ->disableOriginalConstructor() + ->getMock(); - $queryMock->method('get')->willReturnCallback( - function (?string $key) use ($params) { - return $params[$key] ?? null; - } - ); + $this->simpleMock = $this->getMockBuilder(Simple::class) + ->disableOriginalConstructor() + ->onlyMethods(['logout']) + ->getMock(); - $queryMock->method('all')->willReturnCallback( - function (?string $key) use ($params) { - return $params[$key] ?? []; - } - ); - return $queryMock; + $this->sourceServiceMock = $this->getMockBuilder(SourceService::class) + ->onlyMethods(['completeLogout', 'getById']) + ->getMock(); + + $this->controller = $this->getMockBuilder(OIDCLogoutControllerMock::class) + ->onlyMethods($methods) + ->getMock(); + + /** @psalm-suppress UndefinedMethod,MixedMethodCall */ + $this->controller + ->method('getSourceService') + ->willReturn($this->sourceServiceMock); + + $this->controller + ->method('getAuthSource') + ->willReturn($this->simpleMock); + + $this->sourceServiceMock + ->method('getById') + ->with('testSourceId', OAuth2::class) + ->willReturn($this->oauth2Mock); + + $this->controller + ->method('loadState') + ->willReturn($this->stateMock); } } diff --git a/tests/lib/Controller/Oauth2ControllerTest.php b/tests/lib/Controller/Oauth2ControllerTest.php index 2dfaef1..7e8222b 100644 --- a/tests/lib/Controller/Oauth2ControllerTest.php +++ b/tests/lib/Controller/Oauth2ControllerTest.php @@ -6,8 +6,8 @@ use DG\BypassFinals; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use SimpleSAML\Auth\Source; use SimpleSAML\Configuration; use SimpleSAML\Error\AuthSource; use SimpleSAML\Error\Exception; @@ -20,7 +20,6 @@ use SimpleSAML\Module\authoauth2\locators\SourceService; use SimpleSAML\Module\authoauth2\locators\SourceServiceLocator; use SimpleSAML\Utils\HTTP; -use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\Request; // Unless we declare the class here, it is not recognized by phpcs @@ -31,31 +30,6 @@ class Oauth2ControllerMock extends Oauth2Controller use RequestTrait; use ErrorTrait; - public function handleError(OAuth2 $source, array $state, Request $request): void - { - parent::handleError($source, $state, $request); - } - - public function setSource(Source $source): void - { - $this->source = $source; - } - - public function getSource(): ?Source - { - return $this->source; - } - - public function setState(array $state): void - { - $this->state = $state; - } - - public function setSourceId(string $sourceId): void - { - $this->sourceId = $sourceId; - } - public function getExpectedStageState(): string { return $this->expectedStageState; @@ -72,67 +46,110 @@ class Oauth2ControllerTest extends TestCase { /** @var Oauth2ControllerMock */ private $controller; - /** @var Request */ - private $requestMock; /** @var HTTP */ private $httpMock; /** @var \PHPUnit\Framework\MockObject\MockObject|(OAuth2&\PHPUnit\Framework\MockObject\MockObject) */ private $oauth2Mock; + /** @var SourceService */ + private $sourceServiceMock; private array $stateMock; + private array $parametersMock; protected function setUp(): void { - BypassFinals::enable(bypassReadOnly: false); + $this->oauth2Mock = $this->getMockBuilder(OAuth2::class) + ->disableOriginalConstructor() + ->onlyMethods(['finalStep', 'getConfig']) + ->getMock(); + $this->sourceServiceMock = $this->getMockBuilder(SourceService::class) + ->onlyMethods(['getById', 'completeAuth']) + ->getMock(); + - $this->requestMock = $this->getMockBuilder(Request::class)->getMock(); - $this->oauth2Mock = $this->getMockBuilder(OAuth2::class)->disableOriginalConstructor()->getMock(); $this->httpMock = $this->getMockBuilder(HTTP::class)->getMock(); - $this->stateMock = ['state' => 'testState']; + $this->parametersMock = ['state' => OAuth2::STATE_PREFIX . '|statefoo']; + $this->stateMock = [OAuth2::AUTHID => 'testSourceId']; } public function testExpectedConstVariables(): void { - $this->createControllerMock(['parseRequest', 'getSourceService', 'handleError', 'getHttp', 'parseError']); + $this->createControllerMock(['getSourceService', 'loadState']); $this->assertEquals(OAuth2::STAGE_INIT, $this->controller->getExpectedStageState()); $this->assertEquals(OAuth2::STATE_PREFIX . '|', $this->controller->getExpectedPrefix()); } - public function testLinkbackValidCode(): void + public static function requestMethod(): array { - $this->createControllerMock(['parseRequest', 'getSourceService', 'handleError', 'getHttp', 'parseError']); - $this->requestMock->query = $this->createQueryMock(['code' => 'validCode']); + return [ + 'GET' => ['GET'], + 'POST' => ['POST'], + ]; + } + + #[DataProvider('requestMethod')] + public function testLinkbackValidCode(string $requestMethod): void + { + $this->createControllerMock(['getSourceService', 'loadState', 'getHttp']); + $parameters = [ + 'code' => 'validCode', + ...$this->parametersMock, + ]; + + $request = Request::create( + uri: 'https://localhost/auth/authorize', + method: $requestMethod, + parameters: $parameters, + ); $this->oauth2Mock->expects($this->once()) ->method('finalStep') ->with($this->stateMock, 'validCode'); - /** @psalm-suppress UndefinedMethod,MixedMethodCall */ - $this->controller - ->method('getSourceService') - ->willReturn($this->createMock(SourceService::class)); - - $this->controller->linkback($this->requestMock); + $this->sourceServiceMock->expects($this->once()) + ->method('completeAuth') + ->with($this->stateMock); - // Assertions for the success scenario can be done here + $this->controller->linkback($request); } - public function testLinkbackWithNoCode(): void + #[DataProvider('requestMethod')] + public function testLinkbackWithNoCode(string $requestMethod): void { - $this->createControllerMock(['parseRequest', 'getSourceService', 'handleError', 'getHttp', 'parseError']); - $this->requestMock->query = $this->createQueryMock([]); + $this->createControllerMock(['getSourceService', 'loadState', 'handleError']); + + $parameters = [ + ...$this->parametersMock, + ]; + + $request = Request::create( + uri: 'https://localhost/auth/authorize', + method: $requestMethod, + parameters: $parameters, + ); /** @psalm-suppress UndefinedMethod,MixedMethodCall */ $this->controller ->expects($this->once()) ->method('handleError'); - $this->controller->linkback($this->requestMock); + $this->controller->linkback($request); } - public function testLinkbackWithIdentityProviderException(): void + #[DataProvider('requestMethod')] + public function testLinkbackWithIdentityProviderException(string $requestMethod): void { - $this->createControllerMock(['parseRequest', 'getSourceService', 'handleError', 'getHttp', 'parseError']); - $this->requestMock->query = $this->createQueryMock(['code' => 'validCode']); + $this->createControllerMock(['getSourceService', 'loadState', 'getHttp']); + + $parameters = [ + 'code' => 'validCode', + ...$this->parametersMock, + ]; + + $request = Request::create( + uri: 'https://localhost/auth/authorize', + method: $requestMethod, + parameters: $parameters, + ); $this->oauth2Mock->expects($this->once()) ->method('finalStep') @@ -140,36 +157,46 @@ public function testLinkbackWithIdentityProviderException(): void $this->expectException(AuthSource::class); - $this->controller->linkback($this->requestMock); + $this->controller->linkback($request); + } + + public static function configuration(): array + { + return [ //datasets + 'useConsentPage' => [ // dataset 0 + [ + ['useConsentErrorPage' => true], + ], + ], + 'useConsentPage & legacyRoute' => [ // data set 1 + [ + ['useConsentErrorPage' => true, 'useLegacyRoutes' => true], + ], + ], + ]; } /** * @throws Exception */ - public function testHandleErrorWithConsentedError(): void + #[DataProvider('configuration')] + public function testHandleErrorWithConsentedError(array $configuration): void { - $this->createControllerMock(['parseRequest', 'getSourceService', 'getHttp', 'parseError']); - $this->controller->getSource()->method('getAuthId')->willReturn('authId'); - $this->controller - ->getSource() - ->method('getConfig') - ->willReturn(new class (['useConsentErrorPage' => true], '') extends Configuration { - public function getOptionalBoolean($name, $default): bool - { - if (!$this->hasValue($name) && isset($default)) { - return filter_var($default, FILTER_VALIDATE_BOOLEAN); - } - return filter_var($this->getValue($name), FILTER_VALIDATE_BOOLEAN); - } - }); - - $this->requestMock->query = $this->createQueryMock( - ['error' => 'invalid_scope', 'error_description' => 'Invalid scope'] + $this->createControllerMock(['getSourceService', 'loadState', 'getHttp']); + + $request = Request::create( + uri: 'https://localhost/auth/authorize', + method: 'POST', + parameters: [ + ...$this->parametersMock, + 'error' => 'invalid_scope', + 'error_description' => 'Invalid scope', + ], ); - $this->controller->method('parseError') - ->with($this->requestMock) - ->willReturn(['invalid_scope', 'Invalid scope']); + $this->oauth2Mock + ->method('getConfig') + ->willReturn(new Configuration($configuration, 'test')); $this->controller->method('getHttp')->willReturn($this->httpMock); @@ -177,107 +204,73 @@ public function getOptionalBoolean($name, $default): bool ->method('redirectTrustedURL') ->with('http://localhost/module.php/authoauth2/errors/consent'); - $this->controller->handleError($this->controller->getSource(), $this->stateMock, $this->requestMock); + $this->controller->linkback($request); } - public function testHandleErrorWithConsentErrorAtLegacyRoute(): void + public static function oauth2errors(): array { - $this->createControllerMock(['parseRequest', 'getSourceService', 'getHttp', 'parseError']); - $this->controller->getSource()->method('getAuthId')->willReturn('authId'); - $configArray = ['useConsentErrorPage' => true, 'useLegacyRoutes' => true]; - $this->controller - ->getSource() - ->method('getConfig') - ->willReturn(new Configuration($configArray, 'test')); - - $this->requestMock->query = $this->createQueryMock( - ['error' => 'invalid_scope', 'error_description' => 'Invalid scope'] - ); - - $this->controller->method('parseError') - ->with($this->requestMock) - ->willReturn(['invalid_scope', 'Invalid scope']); - - $this->controller->method('getHttp')->willReturn($this->httpMock); - - $this->httpMock->expects($this->once()) - ->method('redirectTrustedURL') - ->with('http://localhost/module.php/authoauth2/errors/consent.php'); - - $this->controller->handleError($this->controller->getSource(), $this->stateMock, $this->requestMock); + return [ + 'oauth2 valid error code' => [ + [ + 'error' => 'invalid_scope', + 'error_description' => 'Invalid scope' + ], + UserAborted::class + ], + 'oauth2 invalid error code' => [ + [ + 'error' => 'invalid_error', + 'error_description' => 'Invalid error' + ], + AuthSource::class + ] + ]; } - public function testHandleErrorWithUserAborted(): void + #[DataProvider('oauth2errors')] + public function testHandleErrorThrowException(array $errorResponse, string $className): void { - $this->createControllerMock(['parseRequest', 'getSourceService', 'getHttp', 'parseError']); - $this->controller->getSource()->method('getAuthId')->willReturn('authId'); + $this->createControllerMock(['getSourceService', 'loadState', 'getHttp']); + + $request = Request::create( + uri: 'https://localhost/auth/authorize', + method: 'GET', + parameters: [ + ...$this->parametersMock, + ...$errorResponse, + ], + ); $configArray = ['useConsentErrorPage' => false]; - $this->controller - ->getSource() + + $this->oauth2Mock ->method('getConfig') ->willReturn(new Configuration($configArray, 'test')); - $this->requestMock->query = $this->createQueryMock( - ['error' => 'invalid_scope', 'error_description' => 'Invalid scope'] - ); - - $this->controller->method('parseError') - ->with($this->requestMock) - ->willReturn(['invalid_scope', 'Invalid scope']); - - $this->controller->method('getHttp')->willReturn($this->httpMock); - - $this->expectException(UserAborted::class); - - $this->controller->handleError($this->controller->getSource(), $this->stateMock, $this->requestMock); - } - - - public function testHandleErrorWithAuthSourceException(): void - { - $this->createControllerMock(['parseRequest', 'getSourceService', 'getHttp', 'parseError']); - $this->controller->getSource()->method('getAuthId')->willReturn('authId'); - - $this->requestMock->query = $this->createQueryMock( - ['error' => 'invalid_error', 'error_description' => 'Invalid Error'] - ); - - $this->controller->method('parseError') - ->with($this->requestMock) - ->willReturn(['invalid_error', 'Invalid Error']); - $this->controller->method('getHttp')->willReturn($this->httpMock); - $this->expectException(AuthSource::class); + $this->expectException($className); - $this->controller->handleError($this->controller->getSource(), $this->stateMock, $this->requestMock); + $this->controller->linkback($request); } - private function createControllerMock(array $methods): void + protected function createControllerMock(array $methods): void { $this->controller = $this->getMockBuilder(Oauth2ControllerMock::class) ->onlyMethods($methods) ->getMock(); - // Stubbing dependencies - $this->controller->setSource($this->oauth2Mock); - $this->controller->setState($this->stateMock); - $this->controller->setSourceId('testSourceId'); - } + /** @psalm-suppress UndefinedMethod,MixedMethodCall */ + $this->controller + ->method('getSourceService') + ->willReturn($this->sourceServiceMock); - private function createQueryMock(array $params): InputBag - { - $queryMock = $this->getMockBuilder(InputBag::class)->getMock(); - $queryMock->method('has')->willReturnCallback( - function (string $key) use ($params) { - return array_key_exists($key, $params); - } - ); - $queryMock->method('get')->willReturnCallback( - function (string $key) use ($params) { - return $params[$key] ?? null; - } - ); - return $queryMock; + $this->sourceServiceMock + ->method('getById') + ->with('testSourceId', OAuth2::class) + ->willReturn($this->oauth2Mock); + + $this->controller + ->method('loadState') + ->willReturn($this->stateMock); } } diff --git a/tests/lib/Controller/Trait/RequestTraitTest.php b/tests/lib/Controller/Trait/RequestTraitTest.php index 1a4b559..f5356de 100644 --- a/tests/lib/Controller/Trait/RequestTraitTest.php +++ b/tests/lib/Controller/Trait/RequestTraitTest.php @@ -176,7 +176,8 @@ public function testParsePostGetRequest(): void $expected = ['error' => 'invalid_request']; - $this->assertEquals($expected, $this->parseRequestParamsSingleton($request)); + $this->parseRequestParamsSingleton($request); + $this->assertEquals($expected, $this->requestParams); $request = Request::create( uri: 'https://localhost/auth/authorize', @@ -184,6 +185,7 @@ public function testParsePostGetRequest(): void parameters: [ 'error' => 'invalid_request'] ); - $this->assertEquals($expected, $this->parseRequestParamsSingleton($request)); + $this->parseRequestParamsSingleton($request); + $this->assertEquals($expected, $this->requestParams); } } From eb348b9b74b4f77a749060bce43a06b4384e667f Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 27 Oct 2024 11:59:07 +0200 Subject: [PATCH 4/6] run request tests both for POST and GET method --- tests/lib/Controller/Oauth2ControllerTest.php | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/tests/lib/Controller/Oauth2ControllerTest.php b/tests/lib/Controller/Oauth2ControllerTest.php index 7e8222b..a598c7e 100644 --- a/tests/lib/Controller/Oauth2ControllerTest.php +++ b/tests/lib/Controller/Oauth2ControllerTest.php @@ -163,15 +163,29 @@ public function testLinkbackWithIdentityProviderException(string $requestMethod) public static function configuration(): array { return [ //datasets - 'useConsentPage' => [ // dataset 0 + 'useConsentPage (GET)' => [ // dataset 0 [ ['useConsentErrorPage' => true], ], + 'GET' ], - 'useConsentPage & legacyRoute' => [ // data set 1 + 'useConsentPage & legacyRoute (GET)' => [ // data set 1 [ ['useConsentErrorPage' => true, 'useLegacyRoutes' => true], ], + 'GET' + ], + 'useConsentPage (POST)' => [ // dataset 0 + [ + ['useConsentErrorPage' => true], + ], + 'POST' + ], + 'useConsentPage & legacyRoute (POST)' => [ // data set 1 + [ + ['useConsentErrorPage' => true, 'useLegacyRoutes' => true], + ], + 'POST' ], ]; } @@ -180,13 +194,13 @@ public static function configuration(): array * @throws Exception */ #[DataProvider('configuration')] - public function testHandleErrorWithConsentedError(array $configuration): void + public function testHandleErrorWithConsentedError(array $configuration, string $requestMethod): void { $this->createControllerMock(['getSourceService', 'loadState', 'getHttp']); $request = Request::create( uri: 'https://localhost/auth/authorize', - method: 'POST', + method: $requestMethod, parameters: [ ...$this->parametersMock, 'error' => 'invalid_scope', @@ -210,31 +224,49 @@ public function testHandleErrorWithConsentedError(array $configuration): void public static function oauth2errors(): array { return [ - 'oauth2 valid error code' => [ + 'oauth2 valid error code (GET)' => [ + [ + 'error' => 'invalid_scope', + 'error_description' => 'Invalid scope' + ], + UserAborted::class, + 'GET' + ], + 'oauth2 invalid error code (GET)' => [ + [ + 'error' => 'invalid_error', + 'error_description' => 'Invalid error' + ], + AuthSource::class, + 'GET' + ], + 'oauth2 valid error code (POST)' => [ [ 'error' => 'invalid_scope', 'error_description' => 'Invalid scope' ], - UserAborted::class + UserAborted::class, + 'POST' ], - 'oauth2 invalid error code' => [ + 'oauth2 invalid error code(POST)' => [ [ 'error' => 'invalid_error', 'error_description' => 'Invalid error' ], - AuthSource::class + AuthSource::class, + 'POST' ] ]; } #[DataProvider('oauth2errors')] - public function testHandleErrorThrowException(array $errorResponse, string $className): void + public function testHandleErrorThrowException(array $errorResponse, string $className, string $requestMethod): void { $this->createControllerMock(['getSourceService', 'loadState', 'getHttp']); $request = Request::create( uri: 'https://localhost/auth/authorize', - method: 'GET', + method: $requestMethod, parameters: [ ...$this->parametersMock, ...$errorResponse, From 8c712be6d7592718c4c0a875eed1d1c5ece3e2ad Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 27 Oct 2024 12:12:28 +0200 Subject: [PATCH 5/6] removed unnecessary space --- tests/lib/Controller/Oauth2ControllerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lib/Controller/Oauth2ControllerTest.php b/tests/lib/Controller/Oauth2ControllerTest.php index a598c7e..b01f83a 100644 --- a/tests/lib/Controller/Oauth2ControllerTest.php +++ b/tests/lib/Controller/Oauth2ControllerTest.php @@ -194,7 +194,7 @@ public static function configuration(): array * @throws Exception */ #[DataProvider('configuration')] - public function testHandleErrorWithConsentedError(array $configuration, string $requestMethod): void + public function testHandleErrorWithConsentedError(array $configuration, string $requestMethod): void { $this->createControllerMock(['getSourceService', 'loadState', 'getHttp']); From 3955c9d34d58f5075c69749f97985b9d57410e27 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 29 Oct 2024 13:33:05 +0200 Subject: [PATCH 6/6] fix Markdown error --- docs/APPLE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/APPLE.md b/docs/APPLE.md index ad38456..2a5533a 100644 --- a/docs/APPLE.md +++ b/docs/APPLE.md @@ -81,4 +81,4 @@ initiate a login to Apple. Non-secret values such as keyId and teamId # Documentation -* (TN3107: Resolving Sign in with Apple response errors)[https://developer.apple.com/documentation/technotes/tn3107-resolving-sign-in-with-apple-response-errors] \ No newline at end of file +* [TN3107: Resolving Sign in with Apple response errors](https://developer.apple.com/documentation/technotes/tn3107-resolving-sign-in-with-apple-response-errors) \ No newline at end of file