From 7dd061dc877a0b517eac4fe041e8b98d6105f14c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Glawaty?= Date: Thu, 25 Jan 2024 05:33:58 +0100 Subject: [PATCH] Init --- .gitattributes | 9 + .github/workflows/coding-style.yml | 52 +++ .github/workflows/coverage.yml | 40 ++ .github/workflows/tests.yml | 41 ++ .gitignore | 4 + .php-cs-fixer.dist.php | 110 +++++ Dockerfile | 27 ++ LICENSE.md | 21 + Makefile | 61 +++ README.md | 190 +++++++++ composer.json | 43 ++ docker-compose.yml | 20 + phpstan.neon | 4 + src/Authentication/AuthenticatorInterface.php | 17 + src/Authorization/AbstractAuthorizator.php | 127 ++++++ src/Authorization/AuthorizationResult.php | 16 + src/Authorization/AuthorizatorInterface.php | 26 ++ src/Authorization/Azure/AzureAuthorizator.php | 56 +++ .../Facebook/FacebookAuthorizator.php | 33 ++ .../Nette/Application/OAuthPresenterTrait.php | 136 ++++++ src/Bridge/Nette/Application/StateEncoder.php | 65 +++ .../Nette/DI/AbstractIntegrationExtension.php | 115 +++++ src/Bridge/Nette/DI/AzureOAuthExtension.php | 38 ++ .../Nette/DI/Config/IntegrationConfig.php | 17 + .../Nette/DI/FacebookOAuthExtension.php | 41 ++ src/Bridge/Nette/DI/OAuthExtension.php | 51 +++ src/Config/Config.php | 39 ++ src/Config/ConfigInterface.php | 19 + src/Config/LazyConfig.php | 41 ++ src/Exception/AuthenticationException.php | 11 + src/Exception/AuthorizationException.php | 42 ++ .../InvalidConfigurationException.php | 23 + src/Exception/MissingOAuthFlowException.php | 23 + src/Exception/OAuthExceptionInterface.php | 11 + .../OAuthFlowIsDisabledException.php | 23 + ...leToConstructAuthorizationUrlException.php | 23 + src/OAuthFlow.php | 67 +++ src/OAuthFlowInterface.php | 35 ++ src/OAuthFlowProvider.php | 53 +++ src/OAuthFlowProviderInterface.php | 20 + tests/.coveralls.yml | 3 + tests/.gitignore | 2 + .../AbstractAuthorizatorTest.phpt | 394 ++++++++++++++++++ .../Azure/AzureAuthorizatorTest.phpt | 64 +++ .../Azure/FacebookAuthorizatorTest.phpt | 65 +++ .../Nette/Application/OAuthPresenter.php | 46 ++ .../Application/OAuthPresenterTraitTest.phpt | 379 +++++++++++++++++ .../Nette/Application/config/config.neon | 17 + .../Nette/DI/AzureOAuthExtensionTest.phpt | 98 +++++ tests/Bridge/Nette/DI/ContainerFactory.php | 39 ++ .../Nette/DI/FacebookOAuthExtensionTest.phpt | 96 +++++ .../DI/IntegrationExtensionTestTrait.php | 80 ++++ tests/Bridge/Nette/DI/OAuthExtensionTest.phpt | 28 ++ .../azure/config.authenticatorAsService.neon | 12 + .../config/azure/config.configAsService.neon | 16 + .../azure/config.configAsStatement.neon | 13 + .../config/azure/config.customFlowName.neon | 10 + .../DI/config/azure/config.disabled.neon | 10 + .../Nette/DI/config/azure/config.minimal.neon | 9 + .../config.authenticatorAsService.neon | 13 + .../facebook/config.configAsService.neon | 17 + .../facebook/config.configAsStatement.neon | 14 + .../facebook/config.customFlowName.neon | 11 + .../DI/config/facebook/config.disabled.neon | 11 + .../DI/config/facebook/config.minimal.neon | 10 + .../Bridge/Nette/DI/config/oauth/config.neon | 2 + tests/Config/ConfigTest.phpt | 85 ++++ tests/Config/LazyConfigTest.phpt | 122 ++++++ tests/Fixtures/AuthenticatorFixture.php | 26 ++ tests/Fixtures/InMemoryUserStorage.php | 47 +++ tests/Fixtures/OAuthFlowFixture.php | 56 +++ tests/OAuthFlowProviderTest.phpt | 90 ++++ tests/OAuthFlowTest.phpt | 183 ++++++++ tests/bootstrap.php | 12 + 74 files changed, 3870 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/coding-style.yml create mode 100644 .github/workflows/coverage.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 Dockerfile create mode 100644 LICENSE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 composer.json create mode 100644 docker-compose.yml create mode 100644 phpstan.neon create mode 100644 src/Authentication/AuthenticatorInterface.php create mode 100644 src/Authorization/AbstractAuthorizator.php create mode 100644 src/Authorization/AuthorizationResult.php create mode 100644 src/Authorization/AuthorizatorInterface.php create mode 100644 src/Authorization/Azure/AzureAuthorizator.php create mode 100644 src/Authorization/Facebook/FacebookAuthorizator.php create mode 100644 src/Bridge/Nette/Application/OAuthPresenterTrait.php create mode 100644 src/Bridge/Nette/Application/StateEncoder.php create mode 100644 src/Bridge/Nette/DI/AbstractIntegrationExtension.php create mode 100644 src/Bridge/Nette/DI/AzureOAuthExtension.php create mode 100644 src/Bridge/Nette/DI/Config/IntegrationConfig.php create mode 100644 src/Bridge/Nette/DI/FacebookOAuthExtension.php create mode 100644 src/Bridge/Nette/DI/OAuthExtension.php create mode 100644 src/Config/Config.php create mode 100644 src/Config/ConfigInterface.php create mode 100644 src/Config/LazyConfig.php create mode 100644 src/Exception/AuthenticationException.php create mode 100644 src/Exception/AuthorizationException.php create mode 100644 src/Exception/InvalidConfigurationException.php create mode 100644 src/Exception/MissingOAuthFlowException.php create mode 100644 src/Exception/OAuthExceptionInterface.php create mode 100644 src/Exception/OAuthFlowIsDisabledException.php create mode 100644 src/Exception/UnableToConstructAuthorizationUrlException.php create mode 100644 src/OAuthFlow.php create mode 100644 src/OAuthFlowInterface.php create mode 100644 src/OAuthFlowProvider.php create mode 100644 src/OAuthFlowProviderInterface.php create mode 100644 tests/.coveralls.yml create mode 100644 tests/.gitignore create mode 100644 tests/Authorization/AbstractAuthorizatorTest.phpt create mode 100644 tests/Authorization/Azure/AzureAuthorizatorTest.phpt create mode 100644 tests/Authorization/Azure/FacebookAuthorizatorTest.phpt create mode 100644 tests/Bridge/Nette/Application/OAuthPresenter.php create mode 100644 tests/Bridge/Nette/Application/OAuthPresenterTraitTest.phpt create mode 100644 tests/Bridge/Nette/Application/config/config.neon create mode 100644 tests/Bridge/Nette/DI/AzureOAuthExtensionTest.phpt create mode 100644 tests/Bridge/Nette/DI/ContainerFactory.php create mode 100644 tests/Bridge/Nette/DI/FacebookOAuthExtensionTest.phpt create mode 100644 tests/Bridge/Nette/DI/IntegrationExtensionTestTrait.php create mode 100644 tests/Bridge/Nette/DI/OAuthExtensionTest.phpt create mode 100644 tests/Bridge/Nette/DI/config/azure/config.authenticatorAsService.neon create mode 100644 tests/Bridge/Nette/DI/config/azure/config.configAsService.neon create mode 100644 tests/Bridge/Nette/DI/config/azure/config.configAsStatement.neon create mode 100644 tests/Bridge/Nette/DI/config/azure/config.customFlowName.neon create mode 100644 tests/Bridge/Nette/DI/config/azure/config.disabled.neon create mode 100644 tests/Bridge/Nette/DI/config/azure/config.minimal.neon create mode 100644 tests/Bridge/Nette/DI/config/facebook/config.authenticatorAsService.neon create mode 100644 tests/Bridge/Nette/DI/config/facebook/config.configAsService.neon create mode 100644 tests/Bridge/Nette/DI/config/facebook/config.configAsStatement.neon create mode 100644 tests/Bridge/Nette/DI/config/facebook/config.customFlowName.neon create mode 100644 tests/Bridge/Nette/DI/config/facebook/config.disabled.neon create mode 100644 tests/Bridge/Nette/DI/config/facebook/config.minimal.neon create mode 100644 tests/Bridge/Nette/DI/config/oauth/config.neon create mode 100644 tests/Config/ConfigTest.phpt create mode 100644 tests/Config/LazyConfigTest.phpt create mode 100644 tests/Fixtures/AuthenticatorFixture.php create mode 100644 tests/Fixtures/InMemoryUserStorage.php create mode 100644 tests/Fixtures/OAuthFlowFixture.php create mode 100644 tests/OAuthFlowProviderTest.phpt create mode 100644 tests/OAuthFlowTest.phpt create mode 100644 tests/bootstrap.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2600fb4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +.github export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.php-cs-fixer.dist.php export-ignore +docker-compose.yml export-ignore +Dockerfile export-ignore +Makefile export-ignore +phpstan.neon export-ignore +tests export-ignore diff --git a/.github/workflows/coding-style.yml b/.github/workflows/coding-style.yml new file mode 100644 index 0000000..b3e7f34 --- /dev/null +++ b/.github/workflows/coding-style.yml @@ -0,0 +1,52 @@ +name: Coding style + +on: + push: + branches: + - main + tags: + - v* + pull_request: + branches: + - main + +jobs: + php-cs-fixer: + name: Php-Cs-Fixer + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + tools: composer:v2 + extensions: uopz + + - name: Install dependencies + run: composer update --no-progress --prefer-dist --prefer-stable --optimize-autoloader --quiet + + - name: Php-Cs-Fixer + run: vendor/bin/php-cs-fixer fix -v --dry-run + + php-stan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + tools: composer:v2 + extensions: uopz + + - name: Install dependencies + run: composer update --no-progress --prefer-dist --prefer-stable --optimize-autoloader --quiet + + - name: PhpStan + run: vendor/bin/phpstan analyse diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..a57c546 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,40 @@ +name: Coverage + +on: + push: + branches: + - main + tags: + - v* + pull_request: + branches: + - main + +jobs: + coverage: + name: Coverage + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + coverage: pcov + extensions: tokenizer, uopz + tools: composer:v2 + + - name: Install dependencies + run: composer update --no-progress --prefer-dist --prefer-stable --optimize-autoloader + + - name: Generate the coverage report + run: vendor/bin/tester -C -s --coverage ./coverage.xml --coverage-src ./src ./tests + + - name: Upload the coverage report + env: + COVERALLS_REPO_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + run: | + wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.5.3/php-coveralls.phar + php php-coveralls.phar --verbose --config tests/.coveralls.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d988f53 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,41 @@ +name: Tests + +on: + push: + branches: + - main + tags: + - v* + pull_request: + branches: + - main + +jobs: + tests: + name: Unit Tests [PHP ${{ matrix.php-versions }}] + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: ['8.1', '8.2'] + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: composer:v2 + extensions: uopz + + - name: Install dependencies + run: composer update --no-progress --prefer-dist --prefer-stable --optimize-autoloader + + - name: Run tests + run: vendor/bin/tester -C -s ./tests + + - name: Install dependencies (lowest) + run: composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable --optimize-autoloader + + - name: Run tests + run: vendor/bin/tester -C -s ./tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63ecbeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +composer.lock +coverage.xml +/vendor +/.idea diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..fd4a2d5 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,110 @@ +in(__DIR__ . '/src') + ->in(__DIR__ . '/tests') + ->name(['*.php', '*.phpt']); + +return (new Config()) + ->registerCustomFixers(new CustomFixers()) + ->setUsingCache(false) + ->setIndent(" ") + ->setRules([ + '@PSR2' => true, + 'array_syntax' => [ + 'syntax' => 'short' + ], + 'trailing_comma_in_multiline' => [ + 'elements' => [ + 'arguments', + 'arrays', + 'match', + 'parameters', + ], + ], + 'constant_case' => [ + 'case' => 'lower', + ], + 'declare_strict_types' => true, + 'phpdoc_align' => true, + 'blank_line_after_opening_tag' => true, + 'blank_line_before_statement' => [ + 'statements' => [ + 'break', + 'continue', + 'declare', + 'return' + ], + ], + 'blank_line_after_namespace' => true, + 'blank_lines_before_namespace' => [ + 'max_line_breaks' => 2, + 'min_line_breaks' => 2, + ], + 'return_type_declaration' => [ + 'space_before' => 'none', + ], + 'ordered_imports' => [ + 'sort_algorithm' => 'alpha', + 'imports_order' => [ + 'class', + 'function', + 'const' + ], + ], + 'no_unused_imports' => true, + 'single_line_after_imports' => true, + 'no_leading_import_slash' => true, + 'global_namespace_import' => [ + 'import_constants' => true, + 'import_functions' => true, + 'import_classes' => true, + ], + 'fully_qualified_strict_types' => true, + 'concat_space' => [ + 'spacing' => 'one', + ], + 'no_superfluous_phpdoc_tags' => [ + 'allow_mixed' => false, + 'remove_inheritdoc' => true, + 'allow_unused_params' => false, + ], + 'no_empty_phpdoc' => true, + 'no_blank_lines_after_phpdoc' => true, + 'phpdoc_trim_consecutive_blank_line_separation' => true, + 'phpdoc_trim' => true, + 'no_extra_blank_lines' => [ + 'tokens' => [ + 'curly_brace_block', + 'extra', + 'parenthesis_brace_block', + 'return', + 'square_brace_block', + 'throw', + 'use', + ], + ], + 'single_trait_insert_per_statement' => true, + 'single_class_element_per_statement' => [ + 'elements' => [ + 'const', + 'property', + ], + ], + 'type_declaration_spaces' => [ + 'elements' => [ + 'function', + 'property', + ], + ], + ConstructorEmptyBracesFixer::name() => true, + MultilinePromotedPropertiesFixer::name() => true, + ]) + ->setRiskyAllowed(true) + ->setFinder($finder); diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..85c6130 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM php:8.1.25-cli-alpine3.18 AS php81 + +CMD ["/bin/sh"] +WORKDIR /var/www/html + +RUN apk add --no-cache --update git +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer +RUN apk add --no-cache ${PHPIZE_DEPS} \ + && pecl install pcov \ + && pecl install uopz-7.1.1 \ + && docker-php-ext-enable pcov uopz + +CMD tail -f /dev/null + +FROM php:8.2.13RC1-cli-alpine3.18 AS php82 + +CMD ["/bin/sh"] +WORKDIR /var/www/html + +RUN apk add --no-cache --update git +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer +RUN apk add --no-cache ${PHPIZE_DEPS} \ + && pecl install pcov \ + && pecl install uopz-7.1.1 \ + && docker-php-ext-enable pcov uopz + +CMD tail -f /dev/null diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..dadb9c9 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2024 '68 Publishers + +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b88cce8 --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +init: + make stop + make start + +stop: + docker compose stop + +start: + docker compose up -d + +down: + docker compose down + +restart: + make stop + make start + +tests.all: + PHP=81 make tests.run + PHP=82 make tests.run + +cs.fix: + PHP=81 make composer.update + docker exec 68publishers.oauth.81 vendor/bin/php-cs-fixer fix -v + +cs.check: + PHP=81 make composer.update + docker exec 68publishers.oauth.81 vendor/bin/php-cs-fixer fix -v --dry-run + +stan: + PHP=81 make composer.update + docker exec 68publishers.oauth.81 vendor/bin/phpstan analyse + +coverage: + PHP=81 make composer.update + docker exec 68publishers.oauth.81 vendor/bin/tester -p phpdbg -C -s --coverage ./coverage.xml --coverage-src ./src ./tests + +composer.update: +ifndef PHP + $(error "PHP argument not set.") +endif + @echo "========== Installing dependencies with PHP $(PHP) ==========" >&2 + docker exec 68publishers.oauth.$(PHP) composer update --no-progress --prefer-dist --prefer-stable --optimize-autoloader --quiet + +composer.update-lowest: +ifndef PHP + $(error "PHP argument not set.") +endif + @echo "========== Installing dependencies with PHP $(PHP) (prefer lowest dependencies) ==========" >&2 + docker exec 68publishers.oauth.$(PHP) composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable --optimize-autoloader --quiet + +tests.run: +ifndef PHP + $(error "PHP argument not set.") +endif + PHP=$(PHP) make composer.update + @echo "========== Running tests with PHP $(PHP) ==========" >&2 + docker exec 68publishers.oauth.$(PHP) vendor/bin/tester -C -s ./tests + PHP=$(PHP) make composer.update-lowest + @echo "========== Running tests with PHP $(PHP) (prefer lowest dependencies) ==========" >&2 + docker exec 68publishers.oauth.$(PHP) vendor/bin/tester -C -s ./tests diff --git a/README.md b/README.md new file mode 100644 index 0000000..3221414 --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +
+

OAuth

+

:bust_in_silhouette: OAuth integration into Nette Framework

+
+ +

+Checks +Coverage Status +Total Downloads +Latest Version +PHP Version +

+ +## Installation + +```sh +$ composer require 68publishers/oauth +``` + +## Configuration + +### Facebook + +```sh +$ composer require league/oauth2-facebook +``` + +```neon +extensions: + 68publishers.oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + 68publishers.facebook: SixtyEightPublishers\OAuth\Bridge\Nette\DI\FacebookOAuthExtension + +68publishers.facebook: + flowName: facebook # default, not necessary to define + config: + enabled: true # default, not necessary to define + clientId: '' + clientSecret: '' + graphApiVersion: '' + options: [] # additional options that are passed into the client + authenticator: App\OAuth\FacebookAuthenticator +``` + +### Azure + +```sh +$ composer require thenetworg/oauth2-azure +``` + +```neon +extensions: + 68publishers.oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + 68publishers.azure: SixtyEightPublishers\OAuth\Bridge\Nette\DI\FacebookOAuthExtension + +68publishers.azure: + flowName: azure # default, not necessary to define + config: + enabled: true # default, not necessary to define + clientId: '' + clientSecret: '' + options: [] # additional options that are passed into the client + authenticator: App\OAuth\AzureAuthenticator +``` + +## Integration + +### Lazy configuration + +Sometimes it may be desirable to provide the configuration for an OAuth client dynamically if, for example, we have settings stored in a database. +We can do this with the following implementation: + +```php +namespace App\OAuth\Config; + +use SixtyEightPublishers\OAuth\Config\Config; +use SixtyEightPublishers\OAuth\Config\LazyConfig; +use App\SettingsProvider; + +final class AzureConfig extends LazyConfig +{ + public function __construct(SettingsProvider $provider) { + parent::__construct( + configFactory: static function (): Config { + return new Config( + flowEnabled: $provider->get('azure.enabled'), + options: [ + 'clientId' => $provider->get('azure.clientId'), + 'clientSecret' => $provider->get('azure.clientSecret'), + ], + ); + } + ); + } +} +``` + +```neon +# ... + +68publishers.azure: + config: App\OAuth\Config\AzureConfig + +# ... +``` + +### Implementing Authenticator + +Authenticator is a class implementing the `AuthenticatorInterface` interface. +This class should return the identity of the user and throw an `AuthenticationException` exception in case of any problem. + +```php +namespace App\OAuth; + +use SixtyEightPublishers\OAuth\Authentication\AuthenticatorInterface; +use SixtyEightPublishers\OAuth\Exception\AuthenticationException; +use SixtyEightPublishers\OAuth\Authorization\AuthorizationResult; +use Nette\Security\IIdentity; +use Nette\Security\SimpleIdentity; + +final class AzureAuthenticator implements AuthenticatorInterface +{ + public function authenticate(string $flowName, AuthorizationResult $authorizationResult): IIdentity + { + $accessToken = $authorizationResult->accessToken; + $resourceOwner = $authorizationResult->resourceOwner; + + if ($userCannotBeAuthenticated) { + throw new AuthenticationException('User can not be authenticated.'); + } + + return new SimpleIdentity(/* ... */); + } +} +``` + +### Implementing OAuth Presenter + +The `OAuthPresenterTrait` trait is used for simple implementation. +Next, you need to define three methods that determine what should happen if the authentication is successful or fails. +All three methods should redirect at the end. + +```php +namespace App\Presenter; + +use Nette\Application\UI\Presenter; +use SixtyEightPublishers\OAuth\Bridge\Nette\Application\OAuthPresenterTrait; +use SixtyEightPublishers\OAuth\Exception\OAuthExceptionInterface; + +final class OAuthPresenter extends Presenter +{ + use OAuthPresenterTrait; + + protected function onAuthorizationRedirectFailed(string $flowName, OAuthExceptionInterface $error): void + { + $this->flashMessage('Authentication failed', 'error'); + $this->redirect('SignIn:'); + } + + abstract protected function onAuthenticationFailed(string $flowName, OAuthExceptionInterface $error): void + { + $this->flashMessage('Authentication failed', 'error'); + $this->redirect('SignIn:'); + } + + abstract protected function onUserAuthenticated(string $flowName): void + { + $this->flashMessage('You have been successfully logged in', 'success'); + $this->redirect('Homepage:'); + } +} +``` + +### Login button + +The login button can be rendered simply as follows + +```latte +Login via Azure +``` + +If you store the request (back link) using `Presenter::storeRequest()` you can also pass it the URL. +Your `OAuthPresenter` will then automatically redirect to this link after successful authentication. + +```latte +Login via Azure +``` + +## License + +The package is distributed under the MIT License. See [LICENSE](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..795414c --- /dev/null +++ b/composer.json @@ -0,0 +1,43 @@ +{ + "name": "68publishers/oauth", + "description": "OAuth integration into Nette Framework", + "keywords": ["68publishers", "oauth", "nette", "azure"], + "type": "project", + "license": "MIT", + "require": { + "php": "^8.1", + "ext-json": "*", + "league/oauth2-client": "^2.7", + "nette/application": "^3.1", + "nette/di": "^3.0", + "nette/http": "^3.2", + "nette/security": "^3.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.48", + "kubawerlos/php-cs-fixer-custom-fixers": "^3.19", + "league/oauth2-facebook": "^2.2", + "mockery/mockery": "^1.6", + "nette/bootstrap": "^3.1", + "nette/tester": "^2.5", + "phpstan/phpstan": "^1.10", + "roave/security-advisories": "dev-latest", + "thenetworg/oauth2-azure": "^2.2" + }, + "conflict": { + "nette/component-model": "<3.0.2" + }, + "autoload": { + "psr-4": { + "SixtyEightPublishers\\OAuth\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "SixtyEightPublishers\\OAuth\\Tests\\": "tests/" + } + }, + "config": { + "sort-packages": true + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f497b99 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: "3.7" + +services: + php81: + build: + context: . + dockerfile: Dockerfile + target: php81 + container_name: 68publishers.oauth.81 + volumes: + - .:/var/www/html:cached + + php82: + build: + context: . + dockerfile: Dockerfile + target: php82 + container_name: 68publishers.oauth.82 + volumes: + - .:/var/www/html:cached diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..1ff472c --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 8 + paths: + - src diff --git a/src/Authentication/AuthenticatorInterface.php b/src/Authentication/AuthenticatorInterface.php new file mode 100644 index 0000000..18f601d --- /dev/null +++ b/src/Authentication/AuthenticatorInterface.php @@ -0,0 +1,17 @@ +createClient( + config: $config, + ); + $session = $this->session->getSection(static::class); + + $options['redirect_uri'] = $redirectUri; + $options = $this->modifyAuthorizationUrlOptions( + client: $client, + config: $config, + options: $options, + ); + + $url = $client->getAuthorizationUrl($options); + + $session->set('state', $client->getState()); + $session->set('redirect_uri', $options['redirect_uri']); + + return $url; + } catch (Throwable $e) { + throw UnableToConstructAuthorizationUrlException::create( + reason: $e->getMessage(), + previous: $e, + ); + } + } + + public function authorize(ConfigInterface $config, array $parameters): AuthorizationResult + { + if (empty($parameters['code'] ?? '')) { + throw AuthorizationException::missingParameter( + name: 'code', + ); + } + + if (empty($parameters['state'] ?? '')) { + throw AuthorizationException::missingParameter( + name: 'state', + ); + } + + try { + $client = $this->createClient( + config: $config, + ); + $session = $this->session->getSection(static::class); + $state = $session->get('state'); + + if (null === $state || $parameters['state'] !== $state) { + $session->remove('state'); + $session->remove('redirect_uri'); + + throw AuthorizationException::possibleCsrfAttack(); + } + + $options = array_filter([ + 'code' => $parameters['code'], + 'redirect_uri' => $session->get('redirect_uri'), + ]); + $options = $this->modifyAccessTokenOptions( + client: $client, + config: $config, + options: $options, + ); + + $token = $client->getAccessToken('authorization_code', $options); + assert($token instanceof AccessToken); + + $resourceOwner = $client->getResourceOwner($token); + + return new AuthorizationResult( + resourceOwner: $resourceOwner, + accessToken: $token, + ); + } catch (AuthorizationException $e) { + throw $e; + } catch (Throwable $e) { + throw AuthorizationException::wrap($e); + } + } + + abstract protected function createClient(ConfigInterface $config): AbstractProvider; + + /** + * @param array $options + * + * @return array + */ + protected function modifyAuthorizationUrlOptions(AbstractProvider $client, ConfigInterface $config, array $options): array + { + return $options; + } + + /** + * @param array $options + * + * @return array + */ + protected function modifyAccessTokenOptions(AbstractProvider $client, ConfigInterface $config, array $options): array + { + return $options; + } +} diff --git a/src/Authorization/AuthorizationResult.php b/src/Authorization/AuthorizationResult.php new file mode 100644 index 0000000..e4d1b46 --- /dev/null +++ b/src/Authorization/AuthorizationResult.php @@ -0,0 +1,16 @@ + $options + * + * @throws UnableToConstructAuthorizationUrlException + */ + public function getAuthorizationUrl(ConfigInterface $config, string $redirectUri, array $options = []): string; + + /** + * @param array $parameters + * + * @throws AuthorizationException + */ + public function authorize(ConfigInterface $config, array $parameters): AuthorizationResult; +} diff --git a/src/Authorization/Azure/AzureAuthorizator.php b/src/Authorization/Azure/AzureAuthorizator.php new file mode 100644 index 0000000..28c4a24 --- /dev/null +++ b/src/Authorization/Azure/AzureAuthorizator.php @@ -0,0 +1,56 @@ + '2.0', + ], + $config->has(self::OptOptions) ? $config->get(self::OptOptions) : [], + [ + self::OptClientId => (string) $config->get(self::OptClientId), + self::OptClientSecret => (string) $config->get(self::OptClientSecret), + ], + ); + + $client = new Azure($options); + + $baseGraphUri = $client->getRootMicrosoftGraphUri(null); + $client->scope = 'openid profile email offline_access ' . $baseGraphUri . '/User.Read'; + + return $client; + } + + protected function modifyAuthorizationUrlOptions(AbstractProvider $client, ConfigInterface $config, array $options): array + { + assert($client instanceof Azure); + $options['scope'] = $client->scope; + + return $options; + } + + protected function modifyAccessTokenOptions(AbstractProvider $client, ConfigInterface $config, array $options): array + { + assert($client instanceof Azure); + $options['scope'] = $client->scope; + + return $options; + } +} diff --git a/src/Authorization/Facebook/FacebookAuthorizator.php b/src/Authorization/Facebook/FacebookAuthorizator.php new file mode 100644 index 0000000..6470c81 --- /dev/null +++ b/src/Authorization/Facebook/FacebookAuthorizator.php @@ -0,0 +1,33 @@ +has(self::OptOptions) ? $config->get(self::OptOptions) : [], + [ + self::OptClientId => (string) $config->get(self::OptClientId), + self::OptClientSecret => (string) $config->get(self::OptClientSecret), + self::OptGraphApiVersion => (string) $config->get(self::OptGraphApiVersion), + ], + ); + + return new Facebook($options); + } +} diff --git a/src/Bridge/Nette/Application/OAuthPresenterTrait.php b/src/Bridge/Nette/Application/OAuthPresenterTrait.php new file mode 100644 index 0000000..0ad61c1 --- /dev/null +++ b/src/Bridge/Nette/Application/OAuthPresenterTrait.php @@ -0,0 +1,136 @@ +oauthFlowProvider = $oauthFlowProvider; + } + + /** + * @throws AbortException + * @throws BadRequestException + * @throws InvalidLinkException + * @throws Exception + */ + public function actionAuthorize(string $type, ?string $backLink = null): void + { + /** @var Presenter $this */ + try { + $flow = $this->getOAuthFlow($type); + $backLink = !empty($backLink) ? $backLink : null; + + $this->redirectUrl( + url: $flow->getAuthorizationUrl( + redirectUri: $this->link('//authenticate', [ + 'type' => $type, + ]), + options: [ + 'state' => StateEncoder::encode([ + 'backLink' => $backLink, + ]), + ], + ), + ); + } catch (OAuthExceptionInterface $e) { + $this->onAuthorizationRedirectFailed( + flowName: $type, + error: $e, + ); + + $this->redirectUrl('/'); + } + } + + /** + * @throws BadRequestException + * @throws AbortException + * @throws JsonException + * @throws NetteAuthenticationException + */ + public function actionAuthenticate(string $type): void + { + /** @var Presenter $this */ + try { + $flow = $this->getOAuthFlow($type); + + $parameters = $this->getHttpRequest()->getUrl()->getQueryParameters(); + $state = StateEncoder::decode($parameters['state'] ?? ''); + $stateData = (array) ($state['data'] ?? []); + $identity = $flow->run($parameters); + + $this->getUser()->login($identity); + + if (!empty($stateData['backLink'] ?? '')) { + $this->restoreRequest($stateData['backLink']); + } + + $this->onUserAuthenticated( + flowName: $type, + ); + } catch (OAuthExceptionInterface $e) { + $this->onAuthenticationFailed( + flowName: $type, + error: $e, + ); + } + + $this->redirectUrl('/'); + } + + /** + * @throws AbortException + */ + abstract protected function onAuthorizationRedirectFailed(string $flowName, OAuthExceptionInterface $error): void; + + /** + * @throws AbortException + */ + abstract protected function onAuthenticationFailed(string $flowName, OAuthExceptionInterface $error): void; + + /** + * @throws AbortException + */ + abstract protected function onUserAuthenticated(string $flowName): void; + + /** + * @throws BadRequestException + */ + private function getOAuthFlow(string $type): OAuthFlowInterface + { + /** @var Presenter $this */ + try { + return $this->oauthFlowProvider->get( + name: $type, + ); + } catch (MissingOAuthFlowException $e) { + $this->error( + message: $e->getMessage(), + httpCode: IResponse::S404_NotFound, + ); + } + } +} diff --git a/src/Bridge/Nette/Application/StateEncoder.php b/src/Bridge/Nette/Application/StateEncoder.php new file mode 100644 index 0000000..deeb640 --- /dev/null +++ b/src/Bridge/Nette/Application/StateEncoder.php @@ -0,0 +1,65 @@ + $customData + * + * @throws Exception + */ + public static function encode(array $customData): string + { + $stateData = json_encode( + value: [ + 'uniq' => bin2hex(random_bytes(16)), + 'data' => $customData, + ], + flags: JSON_THROW_ON_ERROR, + ); + + return strtr( + string: base64_encode( + string: $stateData, + ), + from: '+/=', + to: '-_,', + ); + } + + /** + * @return array + * @throws JsonException + */ + public static function decode(string $state): array + { + $stateData = json_decode( + json: base64_decode( + string: strtr( + string: $state, + from: '-_,', + to: '+/=', + ), + ), + associative: true, + flags: JSON_THROW_ON_ERROR, + ); + + return (array) $stateData; + } +} diff --git a/src/Bridge/Nette/DI/AbstractIntegrationExtension.php b/src/Bridge/Nette/DI/AbstractIntegrationExtension.php new file mode 100644 index 0000000..bdb57bd --- /dev/null +++ b/src/Bridge/Nette/DI/AbstractIntegrationExtension.php @@ -0,0 +1,115 @@ +getFlowConfigOptions(), + [ + 'enabled' => Expect::bool(true)->dynamic(), + ], + ), + )->castTo('array'); + + return Expect::structure([ + 'flowName' => Expect::string($this->getDefaultFlowName()), + 'config' => Expect::anyOf(Expect::string(), Expect::type(Statement::class), $clientConfigSchema) + ->required() + ->before(static function (mixed $factory): Statement|array { + if (is_string($factory)) { + $factory = new Statement($factory); + } + + return $factory; + }), + 'authenticator' => Expect::anyOf(Expect::string(), Expect::type(Statement::class)) + ->required() + ->before(static function (mixed $factory): Statement { + return $factory instanceof Statement ? $factory : new Statement($factory); + }), + ])->castTo(IntegrationConfig::class); + } + + public function loadConfiguration(): void + { + $builder = $this->getContainerBuilder(); + $config = $this->getConfig(); + assert($config instanceof IntegrationConfig); + + $builder->addDefinition($this->prefix('config')) + ->setAutowired(false) + ->setFactory( + $config->config instanceof Statement + ? $config->config + : $this->createConfigFromArray($config->config), + ); + + $builder->addDefinition($this->prefix('authenticator')) + ->setAutowired(false) + ->setFactory($config->authenticator); + + $authorizatorServiceName = $this->defineAuthorizatorService()->getName(); + assert(is_string($authorizatorServiceName)); + + $builder->addDefinition($this->prefix('flow')) + ->setAutowired(false) + ->setFactory( + factory: OAuthFlow::class, + args: [ + 'name' => $config->flowName, + 'config' => new Reference($this->prefix('config')), + 'authorizator' => new Reference($authorizatorServiceName), + 'authenticator' => new Reference($this->prefix('authenticator')), + ], + ) + ->addTag( + tag: OAuthExtension::TagOAuthFlow, + attr: $config->flowName, + ); + } + + abstract protected function getDefaultFlowName(): string; + + /** + * @return array + */ + abstract protected function getFlowConfigOptions(): array; + + abstract protected function defineAuthorizatorService(): ServiceDefinition; + + /** + * @param array $config + */ + private function createConfigFromArray(array $config): Statement + { + $flowEnabled = $config['enabled']; + unset($config['enabled']); + + return new Statement( + entity: Config::class, + arguments: [ + 'flowEnabled' => $flowEnabled, + 'options' => $config, + ], + ); + } +} diff --git a/src/Bridge/Nette/DI/AzureOAuthExtension.php b/src/Bridge/Nette/DI/AzureOAuthExtension.php new file mode 100644 index 0000000..92afebd --- /dev/null +++ b/src/Bridge/Nette/DI/AzureOAuthExtension.php @@ -0,0 +1,38 @@ + Expect::string() + ->required() + ->dynamic(), + AzureAuthorizator::OptClientSecret => Expect::string() + ->required() + ->dynamic(), + AzureAuthorizator::OptOptions => Expect::array(), + ]; + } + + protected function defineAuthorizatorService(): ServiceDefinition + { + return $this->getContainerBuilder() + ->addDefinition($this->prefix('authorizator')) + ->setAutowired(false) + ->setFactory(AzureAuthorizator::class); + } +} diff --git a/src/Bridge/Nette/DI/Config/IntegrationConfig.php b/src/Bridge/Nette/DI/Config/IntegrationConfig.php new file mode 100644 index 0000000..3b24889 --- /dev/null +++ b/src/Bridge/Nette/DI/Config/IntegrationConfig.php @@ -0,0 +1,17 @@ + */ + public Statement|array $config; + + public Statement $authenticator; +} diff --git a/src/Bridge/Nette/DI/FacebookOAuthExtension.php b/src/Bridge/Nette/DI/FacebookOAuthExtension.php new file mode 100644 index 0000000..4b80aa2 --- /dev/null +++ b/src/Bridge/Nette/DI/FacebookOAuthExtension.php @@ -0,0 +1,41 @@ + Expect::string() + ->required() + ->dynamic(), + FacebookAuthorizator::OptClientSecret => Expect::string() + ->required() + ->dynamic(), + FacebookAuthorizator::OptGraphApiVersion => Expect::string() + ->required() + ->dynamic(), + FacebookAuthorizator::OptOptions => Expect::array(), + ]; + } + + protected function defineAuthorizatorService(): ServiceDefinition + { + return $this->getContainerBuilder() + ->addDefinition($this->prefix('authorizator')) + ->setAutowired(false) + ->setFactory(FacebookAuthorizator::class); + } +} diff --git a/src/Bridge/Nette/DI/OAuthExtension.php b/src/Bridge/Nette/DI/OAuthExtension.php new file mode 100644 index 0000000..41b847c --- /dev/null +++ b/src/Bridge/Nette/DI/OAuthExtension.php @@ -0,0 +1,51 @@ +getContainerBuilder(); + + $builder->addDefinition($this->prefix('flow_provider')) + ->setAutowired(OAuthFlowProviderInterface::class) + ->setType(OAuthFlowProviderInterface::class) + ->setFactory(new Reference($this->prefix('flow_provider.default'))); + + $builder->addDefinition($this->prefix('flow_provider.default')) + ->setAutowired(false) + ->setFactory(OAuthFlowProvider::class); + } + + public function beforeCompile(): void + { + $builder = $this->getContainerBuilder(); + $defaultFlowProvider = $builder->getDefinition($this->prefix('flow_provider.default')); + assert($defaultFlowProvider instanceof ServiceDefinition); + + $flowServiceNames = []; + + foreach ($builder->findByTag(self::TagOAuthFlow) as $serviceName => $tag) { + if (!is_string($tag)) { + continue; + } + + $flowServiceNames[$tag] = $serviceName; + } + + $defaultFlowProvider->setArgument('flowServiceNames', $flowServiceNames); + } +} diff --git a/src/Config/Config.php b/src/Config/Config.php new file mode 100644 index 0000000..20b4176 --- /dev/null +++ b/src/Config/Config.php @@ -0,0 +1,39 @@ + $options + */ + public function __construct( + private readonly bool $flowEnabled, + private readonly array $options, + ) {} + + public function isFlowEnabled(): bool + { + return $this->flowEnabled; + } + + public function has(string $key): bool + { + return isset($this->options[$key]); + } + + public function get(string $key): mixed + { + if (!isset($this->options[$key])) { + throw InvalidConfigurationException::missingOption( + optionKey: $key, + ); + } + + return $this->options[$key]; + } +} diff --git a/src/Config/ConfigInterface.php b/src/Config/ConfigInterface.php new file mode 100644 index 0000000..455f67e --- /dev/null +++ b/src/Config/ConfigInterface.php @@ -0,0 +1,19 @@ +getConfig()->isFlowEnabled(); + } + + public function has(string $key): bool + { + return $this->getConfig()->has($key); + } + + public function get(string $key): mixed + { + return $this->getConfig()->get($key); + } + + private function getConfig(): ConfigInterface + { + return null !== $this->config + ? $this->config + : $this->config = ($this->configFactory)(); + } +} diff --git a/src/Exception/AuthenticationException.php b/src/Exception/AuthenticationException.php new file mode 100644 index 0000000..9c73357 --- /dev/null +++ b/src/Exception/AuthenticationException.php @@ -0,0 +1,11 @@ +getMessage(), + ), + previous: $previous, + ); + } +} diff --git a/src/Exception/InvalidConfigurationException.php b/src/Exception/InvalidConfigurationException.php new file mode 100644 index 0000000..7fe926a --- /dev/null +++ b/src/Exception/InvalidConfigurationException.php @@ -0,0 +1,23 @@ +name; + } + + public function isEnabled(): bool + { + return $this->config->isFlowEnabled(); + } + + public function getAuthorizationUrl(string $redirectUri, array $options = []): string + { + $this->throwExceptionIfDisabled(); + + return $this->authorizator->getAuthorizationUrl( + config: $this->config, + redirectUri: $redirectUri, + options: $options, + ); + } + + public function run(array $parameters): IIdentity + { + $this->throwExceptionIfDisabled(); + + return $this->authenticator->authenticate( + flowName: $this->name, + authorizationResult: $this->authorizator->authorize( + config: $this->config, + parameters: $parameters, + ), + ); + } + + /** + * @throws OAuthFlowIsDisabledException + */ + private function throwExceptionIfDisabled(): void + { + if (!$this->isEnabled()) { + throw OAuthFlowIsDisabledException::create( + flowName: $this->name, + ); + } + } +} diff --git a/src/OAuthFlowInterface.php b/src/OAuthFlowInterface.php new file mode 100644 index 0000000..fdeb249 --- /dev/null +++ b/src/OAuthFlowInterface.php @@ -0,0 +1,35 @@ + $options + * + * @throws OAuthFlowIsDisabledException + * @throws UnableToConstructAuthorizationUrlException + */ + public function getAuthorizationUrl(string $redirectUri, array $options = []): string; + + /** + * @param array $parameters + * + * @throws OAuthFlowIsDisabledException + * @throws AuthorizationException + * @throws AuthenticationException + */ + public function run(array $parameters): IIdentity; +} diff --git a/src/OAuthFlowProvider.php b/src/OAuthFlowProvider.php new file mode 100644 index 0000000..1d70682 --- /dev/null +++ b/src/OAuthFlowProvider.php @@ -0,0 +1,53 @@ + $flowServiceNames + */ + public function __construct( + private readonly Container $container, + private readonly array $flowServiceNames = [], + ) {} + + public function get(string $name): OAuthFlowInterface + { + if (!isset($this->flowServiceNames[$name])) { + throw MissingOAuthFlowException::create( + flowName: $name, + ); + } + + try { + $flow = $this->container->getService($this->flowServiceNames[$name]); + } catch (MissingServiceException $e) { + throw MissingOAuthFlowException::create( + flowName: $name, + previous: $e, + ); + } + + assert($flow instanceof OAuthFlowInterface); + + return $flow; + } + + public function all(): array + { + return array_map( + fn (string $name): OAuthFlowInterface => $this->get($name), + array_keys($this->flowServiceNames), + ); + } +} diff --git a/src/OAuthFlowProviderInterface.php b/src/OAuthFlowProviderInterface.php new file mode 100644 index 0000000..36a2ddb --- /dev/null +++ b/src/OAuthFlowProviderInterface.php @@ -0,0 +1,20 @@ + + */ + public function all(): array; +} diff --git a/tests/.coveralls.yml b/tests/.coveralls.yml new file mode 100644 index 0000000..80c1e54 --- /dev/null +++ b/tests/.coveralls.yml @@ -0,0 +1,3 @@ +service_name: github-actions +coverage_clover: coverage.xml +json_path: coverage.json diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..1a18321 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,2 @@ +**.actual +**.expected diff --git a/tests/Authorization/AbstractAuthorizatorTest.phpt b/tests/Authorization/AbstractAuthorizatorTest.phpt new file mode 100644 index 0000000..c5cad44 --- /dev/null +++ b/tests/Authorization/AbstractAuthorizatorTest.phpt @@ -0,0 +1,394 @@ +config, $config); + + return $this->client; + } + + protected function modifyAuthorizationUrlOptions(AbstractProvider $client, ConfigInterface $config, array $options): array + { + Assert::same($this->client, $client); + Assert::same($this->config, $config); + Assert::same([ + 'state' => '__state__', + 'redirect_uri' => 'https://www.example.com', + ], $options); + + $options['test'] = '123'; + + return $options; + } + }; + + $session + ->shouldReceive('getSection') + ->once() + ->with($authorizator::class) + ->andReturn($sessionSection); + + $sessionSection + ->shouldReceive('set') + ->once() + ->with('state', '__state__'); + + $sessionSection + ->shouldReceive('set') + ->once() + ->with('redirect_uri', 'https://www.example.com'); + + $client + ->shouldReceive('getAuthorizationUrl') + ->once() + ->with([ + 'state' => '__state__', + 'redirect_uri' => 'https://www.example.com', + 'test' => '123', + ]) + ->andReturn($authorizationUrl); + + $client + ->shouldReceive('getState') + ->once() + ->andReturn('__state__'); + + Assert::same( + $authorizationUrl, + $authorizator->getAuthorizationUrl( + config: $config, + redirectUri: 'https://www.example.com', + options: [ + 'state' => '__state__', + ], + ), + ); + } + + public function testUnableToConstructAuthorizationUrlExceptionShouldByThrownIfAnyExceptionIsThrownDuringAuthorizationUrlCreation(): void + { + $session = Mockery::mock(Session::class); + $config = Mockery::mock(ConfigInterface::class); + + $authorizator = new class($session) extends AbstractAuthorizator { + public function __construct( + Session $session, + ) { + parent::__construct($session); + } + + protected function createClient(ConfigInterface $config): AbstractProvider + { + throw new Exception('Unable to create the client.'); + } + }; + + Assert::exception( + static fn () => $authorizator->getAuthorizationUrl($config, 'https://www.example.com', ['state' => '__state__']), + UnableToConstructAuthorizationUrlException::class, + 'Unable to construct authorization url: Unable to create the client.', + ); + } + + public function testUserShouldBeAuthorized(): void + { + $client = Mockery::mock(AbstractProvider::class); + $session = Mockery::mock(Session::class); + $sessionSection = Mockery::mock(SessionSection::class); + $config = Mockery::mock(ConfigInterface::class); + + $resourceOwner = Mockery::mock(ResourceOwnerInterface::class); + $accessToken = Mockery::mock(AccessToken::class); + + $authorizator = new class($client, $config, $session) extends AbstractAuthorizator { + public function __construct( + private readonly AbstractProvider $client, + private readonly ConfigInterface $config, + Session $session, + ) { + parent::__construct($session); + } + + protected function createClient(ConfigInterface $config): AbstractProvider + { + Assert::same($this->config, $config); + + return $this->client; + } + + protected function modifyAccessTokenOptions(AbstractProvider $client, ConfigInterface $config, array $options): array + { + Assert::same($this->client, $client); + Assert::same($this->config, $config); + Assert::same([ + 'code' => '__code__', + 'redirect_uri' => 'https://www.example.com', + ], $options); + + $options['test'] = '123'; + + return $options; + } + }; + + $session + ->shouldReceive('getSection') + ->once() + ->with($authorizator::class) + ->andReturn($sessionSection); + + $sessionSection + ->shouldReceive('get') + ->once() + ->with('state') + ->andReturn('__state__'); + + $sessionSection + ->shouldReceive('get') + ->once() + ->with('redirect_uri') + ->andReturn('https://www.example.com'); + + $client + ->shouldReceive('getAccessToken') + ->once() + ->with('authorization_code', ['code' => '__code__', 'redirect_uri' => 'https://www.example.com', 'test' => '123']) + ->andReturn($accessToken); + + $client + ->shouldReceive('getResourceOwner') + ->once() + ->with($accessToken) + ->andReturn($resourceOwner); + + $authorizationResult = $authorizator->authorize( + config: $config, + parameters: [ + 'code' => '__code__', + 'state' => '__state__', + ], + ); + + Assert::same($accessToken, $authorizationResult->accessToken); + Assert::same($resourceOwner, $authorizationResult->resourceOwner); + } + + public function testAuthorizationExceptionShouldBeThrownIfCodeParameterIsMissingDuringAuthorization(): void + { + $client = Mockery::mock(AbstractProvider::class); + $session = Mockery::mock(Session::class); + $config = Mockery::mock(ConfigInterface::class); + + $authorizator = new class($client, $session) extends AbstractAuthorizator { + public function __construct( + private readonly AbstractProvider $client, + Session $session, + ) { + parent::__construct($session); + } + + protected function createClient(ConfigInterface $config): AbstractProvider + { + return $this->client; + } + }; + + Assert::exception( + static fn () => $authorizator->authorize($config, ['state' => '__state__']), + AuthorizationException::class, + 'Authorization failed: Missing parameter with the name "code".', + ); + } + + public function testAuthorizationExceptionShouldBeThrownIfStateParameterIsMissingDuringAuthorization(): void + { + $client = Mockery::mock(AbstractProvider::class); + $session = Mockery::mock(Session::class); + $config = Mockery::mock(ConfigInterface::class); + + $authorizator = new class($client, $session) extends AbstractAuthorizator { + public function __construct( + private readonly AbstractProvider $client, + Session $session, + ) { + parent::__construct($session); + } + + protected function createClient(ConfigInterface $config): AbstractProvider + { + return $this->client; + } + }; + + Assert::exception( + static fn () => $authorizator->authorize($config, ['code' => '__code__']), + AuthorizationException::class, + 'Authorization failed: Missing parameter with the name "state".', + ); + } + + public function testAuthorizationExceptionShouldBeThrownIfAnyExceptionIsThrownDuringAuthorization(): void + { + $session = Mockery::mock(Session::class); + $config = Mockery::mock(ConfigInterface::class); + + $authorizator = new class($session) extends AbstractAuthorizator { + protected function createClient(ConfigInterface $config): AbstractProvider + { + throw new Exception('Unable to create the client.'); + } + }; + + Assert::exception( + static fn () => $authorizator->authorize($config, ['code' => '__code__', 'state' => '__state__']), + AuthorizationException::class, + 'Authorization failed: Unable to create the client.', + ); + } + + public function testAuthorizationExceptionShouldBeThrownIfStateIsMissingInSessionDuringAuthorization(): void + { + $client = Mockery::mock(AbstractProvider::class); + $session = Mockery::mock(Session::class); + $sessionSection = Mockery::mock(SessionSection::class); + $config = Mockery::mock(ConfigInterface::class); + + $authorizator = new class($client, $session) extends AbstractAuthorizator { + public function __construct( + private readonly AbstractProvider $client, + Session $session, + ) { + parent::__construct($session); + } + + protected function createClient(ConfigInterface $config): AbstractProvider + { + return $this->client; + } + }; + + $session + ->shouldReceive('getSection') + ->once() + ->with($authorizator::class) + ->andReturn($sessionSection); + + $sessionSection + ->shouldReceive('get') + ->once() + ->with('state') + ->andReturn(null); + + $sessionSection + ->shouldReceive('remove') + ->once() + ->with('state'); + + $sessionSection + ->shouldReceive('remove') + ->once() + ->with('redirect_uri'); + + Assert::exception( + static fn () => $authorizator->authorize($config, ['code' => '__code__', 'state' => '__state__']), + AuthorizationException::class, + 'Authorization failed: Possible CSRF attack.', + ); + } + + public function testAuthorizationExceptionShouldBeThrownIfStateInSessionIsDifferentDuringAuthorization(): void + { + $client = Mockery::mock(AbstractProvider::class); + $session = Mockery::mock(Session::class); + $sessionSection = Mockery::mock(SessionSection::class); + $config = Mockery::mock(ConfigInterface::class); + + $authorizator = new class($client, $session) extends AbstractAuthorizator { + public function __construct( + private readonly AbstractProvider $client, + Session $session, + ) { + parent::__construct($session); + } + + protected function createClient(ConfigInterface $config): AbstractProvider + { + return $this->client; + } + }; + + $session + ->shouldReceive('getSection') + ->once() + ->with($authorizator::class) + ->andReturn($sessionSection); + + $sessionSection + ->shouldReceive('get') + ->once() + ->with('state') + ->andReturn(null); + + $sessionSection + ->shouldReceive('remove') + ->once() + ->with('state'); + + $sessionSection + ->shouldReceive('remove') + ->once() + ->with('redirect_uri'); + + Assert::exception( + static fn () => $authorizator->authorize($config, ['code' => '__code__', 'state' => '__different_state__']), + AuthorizationException::class, + 'Authorization failed: Possible CSRF attack.', + ); + } + + protected function tearDown(): void + { + Mockery::close(); + } +} + +(new AbstractAuthorizatorTest())->run(); diff --git a/tests/Authorization/Azure/AzureAuthorizatorTest.phpt b/tests/Authorization/Azure/AzureAuthorizatorTest.phpt new file mode 100644 index 0000000..e69751f --- /dev/null +++ b/tests/Authorization/Azure/AzureAuthorizatorTest.phpt @@ -0,0 +1,64 @@ +shouldReceive('getSection') + ->once() + ->with(AzureAuthorizator::class) + ->andReturn($sessionSection); + + $sessionSection + ->shouldReceive('set'); + + $config = new Config( + flowEnabled: true, + options: [ + AzureAuthorizator::OptClientId => '498bb796-0410-4a6c-ba95-b4855ea0c900', + AzureAuthorizator::OptClientSecret => 'secret', + ], + ); + + $authorizator = new AzureAuthorizator($session); + + $url = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?' + . 'state=__state__' + . '&redirect_uri=https%3A%2F%2Fwww.example.com' + . '&scope=openid%20profile%20email%20offline_access%20https%3A%2F%2Fgraph.microsoft.com%2FUser.Read' + . '&response_type=code' + . '&approval_prompt=auto' + . '&client_id=498bb796-0410-4a6c-ba95-b4855ea0c900'; + + Assert::same( + $url, + $authorizator->getAuthorizationUrl( + config: $config, + redirectUri: 'https://www.example.com', + options: [ + 'state' => '__state__', + ], + ), + ); + } +} + +(new AzureAuthorizatorTest())->run(); diff --git a/tests/Authorization/Azure/FacebookAuthorizatorTest.phpt b/tests/Authorization/Azure/FacebookAuthorizatorTest.phpt new file mode 100644 index 0000000..a6d6248 --- /dev/null +++ b/tests/Authorization/Azure/FacebookAuthorizatorTest.phpt @@ -0,0 +1,65 @@ +shouldReceive('getSection') + ->once() + ->with(FacebookAuthorizator::class) + ->andReturn($sessionSection); + + $sessionSection + ->shouldReceive('set'); + + $config = new Config( + flowEnabled: true, + options: [ + FacebookAuthorizator::OptClientId => '498bb796-0410-4a6c-ba95-b4855ea0c900', + FacebookAuthorizator::OptClientSecret => 'secret', + FacebookAuthorizator::OptGraphApiVersion => 'v3.2', + ], + ); + + $authorizator = new FacebookAuthorizator($session); + + $url = 'https://www.facebook.com/v3.2/dialog/oauth?' + . 'state=__state__' + . '&redirect_uri=https%3A%2F%2Fwww.example.com' + . '&scope=public_profile%2Cemail' + . '&response_type=code' + . '&approval_prompt=auto' + . '&client_id=498bb796-0410-4a6c-ba95-b4855ea0c900'; + + Assert::same( + $url, + $authorizator->getAuthorizationUrl( + config: $config, + redirectUri: 'https://www.example.com', + options: [ + 'state' => '__state__', + ], + ), + ); + } +} + +(new FacebookAuthorizatorTest())->run(); diff --git a/tests/Bridge/Nette/Application/OAuthPresenter.php b/tests/Bridge/Nette/Application/OAuthPresenter.php new file mode 100644 index 0000000..2d645a2 --- /dev/null +++ b/tests/Bridge/Nette/Application/OAuthPresenter.php @@ -0,0 +1,46 @@ +restoredRequest = $key; + } + + protected function onAuthorizationRedirectFailed(string $flowName, OAuthExceptionInterface $error): void + { + $this->onAuthorizationRedirectFailedHandler && ($this->onAuthorizationRedirectFailedHandler)($flowName, $error, $this); + } + + protected function onAuthenticationFailed(string $flowName, OAuthExceptionInterface $error): void + { + $this->onAuthenticationFailedHandler && ($this->onAuthenticationFailedHandler)($flowName, $error, $this); + } + + protected function onUserAuthenticated(string $flowName): void + { + $this->onUserAuthenticated && ($this->onUserAuthenticated)($flowName, $this); + } +} diff --git a/tests/Bridge/Nette/Application/OAuthPresenterTraitTest.phpt b/tests/Bridge/Nette/Application/OAuthPresenterTraitTest.phpt new file mode 100644 index 0000000..547aaef --- /dev/null +++ b/tests/Bridge/Nette/Application/OAuthPresenterTraitTest.phpt @@ -0,0 +1,379 @@ + null], $decodedState['data']); + + return 'https://oauth.service.com'; + }, + runHandler: static function () {}, + ); + + $container = $this->createContainer( + flow: $flow, + url: new UrlScript( + url: 'https://www.example.com/o-auth/authorize?type=test', + ), + ); + $presenter = $this->createPresenter( + container: $container, + ); + + $response = $presenter->run( + request: $this->createApplicationRequest( + container: $container, + ), + ); + + Assert::type(RedirectResponse::class, $response); + assert($response instanceof RedirectResponse); + + Assert::same('https://oauth.service.com', $response->getUrl()); + } + + public function testUserShouldBeRedirectedToAuthorizationUrlWithBackLinkParameter(): void + { + $flow = new OAuthFlowFixture( + name: 'test', + enabled: true, + getAuthorizationUrlHandler: static function (string $redirectUri, array $options) { + Assert::same('https://www.example.com/o-auth/authenticate?type=test', $redirectUri); + Assert::hasKey('state', $options); + + $decodedState = StateEncoder::decode($options['state']); + + Assert::hasKey('uniq', $decodedState); + Assert::hasKey('data', $decodedState); + Assert::type('string', $decodedState['uniq']); + Assert::same(['backLink' => 'abc'], $decodedState['data']); + + return 'https://oauth.service.com'; + }, + runHandler: static function () {}, + ); + + $container = $this->createContainer( + flow: $flow, + url: new UrlScript( + url: 'https://www.example.com/o-auth/authorize?type=test&backLink=abc', + ), + ); + $presenter = $this->createPresenter( + container: $container, + ); + + $response = $presenter->run( + request: $this->createApplicationRequest( + container: $container, + ), + ); + + Assert::type(RedirectResponse::class, $response); + assert($response instanceof RedirectResponse); + + Assert::same('https://oauth.service.com', $response->getUrl()); + } + + public function testAuthorizationActionFailed(): void + { + $exception = new AuthorizationException('Authorization failed.'); + $flow = new OAuthFlowFixture( + name: 'test', + enabled: true, + getAuthorizationUrlHandler: static function () use ($exception) { + throw $exception; + }, + runHandler: static function () {}, + ); + + $container = $this->createContainer( + flow: $flow, + url: new UrlScript( + url: 'https://www.example.com/o-auth/authorize?type=test', + ), + ); + $presenter = $this->createPresenter( + container: $container, + ); + + $handlerCalled = false; + $presenter->onAuthorizationRedirectFailedHandler = static function (string $flowName, OAuthExceptionInterface $error) use ($exception, &$handlerCalled): void { + Assert::same('test', $flowName); + Assert::same($exception, $error); + + $handlerCalled = true; + }; + + $response = $presenter->run( + request: $this->createApplicationRequest( + container: $container, + ), + ); + + Assert::type(RedirectResponse::class, $response); + assert($response instanceof RedirectResponse); + + Assert::same('/', $response->getUrl()); + Assert::true($handlerCalled); + } + + public function testUserShouldBeAuthenticated(): void + { + $identity = new SimpleIdentity( + id: 1, + ); + $state = StateEncoder::encode([]); + + $flow = new OAuthFlowFixture( + name: 'test', + enabled: true, + getAuthorizationUrlHandler: static function () {}, + runHandler: static function (array $parameters) use ($identity, $state) { + Assert::same( + [ + 'type' => 'test', + 'code' => '__code__', + 'state' => $state, + ], + $parameters, + ); + + return $identity; + }, + ); + + $container = $this->createContainer( + flow: $flow, + url: new UrlScript( + url: 'https://www.example.com/o-auth/authenticate?type=test&code=__code__&state=' . rawurlencode($state), + ), + ); + $presenter = $this->createPresenter( + container: $container, + ); + $user = $container->getByType(User::class); + + $handlerCalled = false; + $presenter->onUserAuthenticated = static function (string $flowName) use (&$handlerCalled): void { + Assert::same('test', $flowName); + + $handlerCalled = true; + }; + + Assert::false($user->isLoggedIn()); + + $response = $presenter->run( + request: $this->createApplicationRequest( + container: $container, + ), + ); + + Assert::type(RedirectResponse::class, $response); + assert($response instanceof RedirectResponse); + + Assert::same('/', $response->getUrl()); + Assert::null($presenter->restoredRequest); + Assert::true($handlerCalled); + Assert::true($user->isLoggedIn()); + Assert::same($identity, $user->getIdentity()); + } + + public function testUserShouldBeAuthenticatedWithRestoringBackLinkRequest(): void + { + $identity = new SimpleIdentity( + id: 1, + ); + $state = StateEncoder::encode([ + 'backLink' => 'abc', + ]); + + $flow = new OAuthFlowFixture( + name: 'test', + enabled: true, + getAuthorizationUrlHandler: static function () {}, + runHandler: static function (array $parameters) use ($identity, $state) { + Assert::same( + [ + 'type' => 'test', + 'code' => '__code__', + 'state' => $state, + ], + $parameters, + ); + + return $identity; + }, + ); + + $container = $this->createContainer( + flow: $flow, + url: new UrlScript( + url: 'https://www.example.com/o-auth/authenticate?type=test&code=__code__&state=' . rawurlencode($state), + ), + ); + $presenter = $this->createPresenter( + container: $container, + ); + $user = $container->getByType(User::class); + + $handlerCalled = false; + $presenter->onUserAuthenticated = static function (string $flowName) use (&$handlerCalled): void { + Assert::same('test', $flowName); + + $handlerCalled = true; + }; + + Assert::false($user->isLoggedIn()); + + $response = $presenter->run( + request: $this->createApplicationRequest( + container: $container, + ), + ); + + Assert::type(RedirectResponse::class, $response); + assert($response instanceof RedirectResponse); + + Assert::same('/', $response->getUrl()); + Assert::same('abc', $presenter->restoredRequest); + Assert::true($handlerCalled); + Assert::true($user->isLoggedIn()); + Assert::same($identity, $user->getIdentity()); + } + + public function testUserAuthenticationFailed(): void + { + $exception = new AuthenticationException('Authentication failed.'); + $flow = new OAuthFlowFixture( + name: 'test', + enabled: true, + getAuthorizationUrlHandler: static function () {}, + runHandler: static function () use ($exception) { + throw $exception; + }, + ); + + $container = $this->createContainer( + flow: $flow, + url: new UrlScript( + url: 'https://www.example.com/o-auth/authenticate?type=test&code=__code__&state=' . rawurlencode(StateEncoder::encode([])), + ), + ); + $presenter = $this->createPresenter( + container: $container, + ); + + $handlerCalled = false; + $presenter->onAuthenticationFailedHandler = static function (string $flowName, OAuthExceptionInterface $error) use ($exception, &$handlerCalled): void { + Assert::same('test', $flowName); + Assert::same($exception, $error); + + $handlerCalled = true; + }; + + $response = $presenter->run( + request: $this->createApplicationRequest( + container: $container, + ), + ); + + Assert::type(RedirectResponse::class, $response); + assert($response instanceof RedirectResponse); + + Assert::same('/', $response->getUrl()); + Assert::true($handlerCalled); + } + + private function createContainer(OAuthFlowInterface $flow, UrlScript $url): Container + { + $container = ContainerFactory::create(__DIR__ . '/config/config.neon'); + + $container->addService( + name: 'flow.test', + service: $flow, + ); + + if ($container->hasService('http.request')) { + $container->removeService('http.request'); + } + + $container->addService( + name: 'http.request', + service: new HttpRequest( + url: $url, + ), + ); + + return $container; + } + + private function createPresenter(Container $container): OAuthPresenter + { + $presenterFactory = $container->getByType(IPresenterFactory::class); + $presenter = $presenterFactory->createPresenter('OAuth'); + assert($presenter instanceof OAuthPresenter); + + return $presenter; + } + + private function createApplicationRequest(Container $container): Request + { + $httpRequest = $container->getByType(HttpRequest::class); + $router = $container->getByType(Router::class); + + $params = $router->match($httpRequest); + $presenter = $params['presenter'] ?? null; + + return new Request( + $presenter, + $httpRequest->getMethod(), + $params, + $httpRequest->getPost(), + $httpRequest->getFiles(), + [Request::SECURED => $httpRequest->isSecured()], + ); + } +} + +(new OAuthPresenterTraitTest())->run(); diff --git a/tests/Bridge/Nette/Application/config/config.neon b/tests/Bridge/Nette/Application/config/config.neon new file mode 100644 index 0000000..4e79cef --- /dev/null +++ b/tests/Bridge/Nette/Application/config/config.neon @@ -0,0 +1,17 @@ +application: + scanDirs: no + scanComposer: no + catchExceptions: no + mapping: + *: SixtyEightPublishers\OAuth\Tests\Bridge\Nette\Application\*Presenter + +services: + - SixtyEightPublishers\OAuth\Tests\Bridge\Nette\Application\OAuthPresenter + - Nette\Application\Routers\Route('/[/]') + - SixtyEightPublishers\OAuth\OAuthFlowProvider( + flowServiceNames: [ + test: flow.test + ] + ) + + nette.userStorage: SixtyEightPublishers\OAuth\Tests\Fixtures\InMemoryUserStorage diff --git a/tests/Bridge/Nette/DI/AzureOAuthExtensionTest.phpt b/tests/Bridge/Nette/DI/AzureOAuthExtensionTest.phpt new file mode 100644 index 0000000..f81caf9 --- /dev/null +++ b/tests/Bridge/Nette/DI/AzureOAuthExtensionTest.phpt @@ -0,0 +1,98 @@ + [ + __DIR__ . '/config/azure/config.minimal.neon', + 'azure', + true, + AuthenticatorFixture::class, + Config::class, + [ + AzureAuthorizator::OptClientId => 'client', + AzureAuthorizator::OptClientSecret => 'secret', + ], + ], + 'disabled' => [ + __DIR__ . '/config/azure/config.disabled.neon', + 'azure', + false, + AuthenticatorFixture::class, + Config::class, + [ + AzureAuthorizator::OptClientId => 'client', + AzureAuthorizator::OptClientSecret => 'secret', + ], + ], + 'custom flow name' => [ + __DIR__ . '/config/azure/config.customFlowName.neon', + 'custom_azure', + true, + AuthenticatorFixture::class, + Config::class, + [ + AzureAuthorizator::OptClientId => 'client', + AzureAuthorizator::OptClientSecret => 'secret', + ], + ], + 'config as statement' => [ + __DIR__ . '/config/azure/config.configAsStatement.neon', + 'azure', + true, + AuthenticatorFixture::class, + Config::class, + [ + AzureAuthorizator::OptClientId => 'client', + AzureAuthorizator::OptClientSecret => 'secret', + ], + ], + 'config as service' => [ + __DIR__ . '/config/azure/config.configAsService.neon', + 'azure', + true, + AuthenticatorFixture::class, + Config::class, + [ + AzureAuthorizator::OptClientId => 'client', + AzureAuthorizator::OptClientSecret => 'secret', + ], + ], + 'authenticator as service' => [ + __DIR__ . '/config/azure/config.authenticatorAsService.neon', + 'azure', + true, + AuthenticatorFixture::class, + Config::class, + [ + AzureAuthorizator::OptClientId => 'client', + AzureAuthorizator::OptClientSecret => 'secret', + ], + ], + ]; + } + + protected function getAuthorizatorClassname(): string + { + return AzureAuthorizator::class; + } +} + +(new AzureOAuthExtensionTest())->run(); diff --git a/tests/Bridge/Nette/DI/ContainerFactory.php b/tests/Bridge/Nette/DI/ContainerFactory.php new file mode 100644 index 0000000..df92ab8 --- /dev/null +++ b/tests/Bridge/Nette/DI/ContainerFactory.php @@ -0,0 +1,39 @@ + $configFiles + */ + public static function create(string|array $configFiles): Container + { + $tempDir = sys_get_temp_dir() . '/' . uniqid('68publishers:AmpClientPhp', true); + + Helpers::purge($tempDir); + + $configurator = new Configurator(); + $configurator->setTempDirectory($tempDir); + $configurator->setDebugMode(false); + $configurator->addStaticParameters([ + 'resources' => __DIR__ . '/../../../resources', + ]); + + foreach ((array) $configFiles as $configFile) { + $configurator->addConfig($configFile); + } + + return $configurator->createContainer(); + } +} diff --git a/tests/Bridge/Nette/DI/FacebookOAuthExtensionTest.phpt b/tests/Bridge/Nette/DI/FacebookOAuthExtensionTest.phpt new file mode 100644 index 0000000..7b5af0a --- /dev/null +++ b/tests/Bridge/Nette/DI/FacebookOAuthExtensionTest.phpt @@ -0,0 +1,96 @@ + [ + __DIR__ . '/config/facebook/config.minimal.neon', + 'facebook', + true, + AuthenticatorFixture::class, + Config::class, + [ + FacebookAuthorizator::OptClientId => 'client', + FacebookAuthorizator::OptClientSecret => 'secret', + ], + ], + 'disabled' => [ + __DIR__ . '/config/facebook/config.disabled.neon', + 'facebook', + false, + AuthenticatorFixture::class, + Config::class, + [ + FacebookAuthorizator::OptClientId => 'client', + FacebookAuthorizator::OptClientSecret => 'secret', + ], + ], + 'custom flow name' => [ + __DIR__ . '/config/facebook/config.customFlowName.neon', + 'custom_facebook', + true, + AuthenticatorFixture::class, + Config::class, + [ + FacebookAuthorizator::OptClientId => 'client', + FacebookAuthorizator::OptClientSecret => 'secret', + ], + ], + 'config as statement' => [ + __DIR__ . '/config/facebook/config.configAsStatement.neon', + 'facebook', + true, + AuthenticatorFixture::class, + Config::class, + [ + FacebookAuthorizator::OptClientId => 'client', + FacebookAuthorizator::OptClientSecret => 'secret', + ], + ], + 'config as service' => [ + __DIR__ . '/config/facebook/config.configAsService.neon', + 'facebook', + true, + AuthenticatorFixture::class, + Config::class, + [ + FacebookAuthorizator::OptClientId => 'client', + FacebookAuthorizator::OptClientSecret => 'secret', + ], + ], + 'authenticator as service' => [ + __DIR__ . '/config/facebook/config.authenticatorAsService.neon', + 'facebook', + true, + AuthenticatorFixture::class, + Config::class, + [ + FacebookAuthorizator::OptClientId => 'client', + FacebookAuthorizator::OptClientSecret => 'secret', + ], + ], + ]; + } + + protected function getAuthorizatorClassname(): string + { + return FacebookAuthorizator::class; + } +} + +(new FacebookOAuthExtensionTest())->run(); diff --git a/tests/Bridge/Nette/DI/IntegrationExtensionTestTrait.php b/tests/Bridge/Nette/DI/IntegrationExtensionTestTrait.php new file mode 100644 index 0000000..97b3672 --- /dev/null +++ b/tests/Bridge/Nette/DI/IntegrationExtensionTestTrait.php @@ -0,0 +1,80 @@ +getByType(OAuthFlowProviderInterface::class); + $flow = $provider->get($flowName); + + Assert::type(OAuthFlow::class, $flow); + assert($flow instanceof OAuthFlow); + + Assert::same($flowName, $flow->getName()); + Assert::same($enabled, $flow->isEnabled()); + + [$authorizator, $authenticator, $config] = call_user_func( + callback: Closure::bind( + closure: static fn (): array => [ + $flow->authorizator, + $flow->authenticator, + $flow->config, + ], + newThis: null, + newScope: OAuthFlow::class, + ), + ); + + Assert::type($this->getAuthorizatorClassname(), $authorizator); + Assert::type($authenticatorClassname, $authenticator); + Assert::type($configClassname, $config); + + foreach ($configOptions as $key => $option) { + Assert::same($option, $config->get($key)); + } + } + + /** + * @return array, + * 4: class-string, + * 5: array, + * }> + */ + abstract public function extensionShouldBeRegisteredDataProvider(): array; + + /** + * @return class-string + */ + abstract protected function getAuthorizatorClassname(): string; +} diff --git a/tests/Bridge/Nette/DI/OAuthExtensionTest.phpt b/tests/Bridge/Nette/DI/OAuthExtensionTest.phpt new file mode 100644 index 0000000..39939f0 --- /dev/null +++ b/tests/Bridge/Nette/DI/OAuthExtensionTest.phpt @@ -0,0 +1,28 @@ +getByType(OAuthFlowProviderInterface::class); + + Assert::type(OAuthFlowProvider::class, $provider); + Assert::same([], $provider->all()); + } +} + +(new OAuthExtensionTest())->run(); diff --git a/tests/Bridge/Nette/DI/config/azure/config.authenticatorAsService.neon b/tests/Bridge/Nette/DI/config/azure/config.authenticatorAsService.neon new file mode 100644 index 0000000..584b45a --- /dev/null +++ b/tests/Bridge/Nette/DI/config/azure/config.authenticatorAsService.neon @@ -0,0 +1,12 @@ +extensions: + oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + oauth.azure: SixtyEightPublishers\OAuth\Bridge\Nette\DI\AzureOAuthExtension + +oauth.azure: + config: + clientId: client + clientSecret: secret + authenticator: @azure_authenticator + +services: + azure_authenticator: SixtyEightPublishers\OAuth\Tests\Fixtures\AuthenticatorFixture diff --git a/tests/Bridge/Nette/DI/config/azure/config.configAsService.neon b/tests/Bridge/Nette/DI/config/azure/config.configAsService.neon new file mode 100644 index 0000000..c9a817d --- /dev/null +++ b/tests/Bridge/Nette/DI/config/azure/config.configAsService.neon @@ -0,0 +1,16 @@ +extensions: + oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + oauth.azure: SixtyEightPublishers\OAuth\Bridge\Nette\DI\AzureOAuthExtension + +oauth.azure: + config: @azure_config + authenticator: SixtyEightPublishers\OAuth\Tests\Fixtures\AuthenticatorFixture + +services: + azure_config: SixtyEightPublishers\OAuth\Config\Config( + flowEnabled: true + options: [ + clientId: client + clientSecret: secret + ] + ) diff --git a/tests/Bridge/Nette/DI/config/azure/config.configAsStatement.neon b/tests/Bridge/Nette/DI/config/azure/config.configAsStatement.neon new file mode 100644 index 0000000..0a6919a --- /dev/null +++ b/tests/Bridge/Nette/DI/config/azure/config.configAsStatement.neon @@ -0,0 +1,13 @@ +extensions: + oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + oauth.azure: SixtyEightPublishers\OAuth\Bridge\Nette\DI\AzureOAuthExtension + +oauth.azure: + config: SixtyEightPublishers\OAuth\Config\Config( + flowEnabled: true + options: [ + clientId: client + clientSecret: secret + ] + ) + authenticator: SixtyEightPublishers\OAuth\Tests\Fixtures\AuthenticatorFixture diff --git a/tests/Bridge/Nette/DI/config/azure/config.customFlowName.neon b/tests/Bridge/Nette/DI/config/azure/config.customFlowName.neon new file mode 100644 index 0000000..33c4264 --- /dev/null +++ b/tests/Bridge/Nette/DI/config/azure/config.customFlowName.neon @@ -0,0 +1,10 @@ +extensions: + oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + oauth.azure: SixtyEightPublishers\OAuth\Bridge\Nette\DI\AzureOAuthExtension + +oauth.azure: + flowName: custom_azure + config: + clientId: client + clientSecret: secret + authenticator: SixtyEightPublishers\OAuth\Tests\Fixtures\AuthenticatorFixture diff --git a/tests/Bridge/Nette/DI/config/azure/config.disabled.neon b/tests/Bridge/Nette/DI/config/azure/config.disabled.neon new file mode 100644 index 0000000..5559930 --- /dev/null +++ b/tests/Bridge/Nette/DI/config/azure/config.disabled.neon @@ -0,0 +1,10 @@ +extensions: + oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + oauth.azure: SixtyEightPublishers\OAuth\Bridge\Nette\DI\AzureOAuthExtension + +oauth.azure: + config: + enabled: no + clientId: client + clientSecret: secret + authenticator: SixtyEightPublishers\OAuth\Tests\Fixtures\AuthenticatorFixture diff --git a/tests/Bridge/Nette/DI/config/azure/config.minimal.neon b/tests/Bridge/Nette/DI/config/azure/config.minimal.neon new file mode 100644 index 0000000..b8cd81a --- /dev/null +++ b/tests/Bridge/Nette/DI/config/azure/config.minimal.neon @@ -0,0 +1,9 @@ +extensions: + oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + oauth.azure: SixtyEightPublishers\OAuth\Bridge\Nette\DI\AzureOAuthExtension + +oauth.azure: + config: + clientId: client + clientSecret: secret + authenticator: SixtyEightPublishers\OAuth\Tests\Fixtures\AuthenticatorFixture diff --git a/tests/Bridge/Nette/DI/config/facebook/config.authenticatorAsService.neon b/tests/Bridge/Nette/DI/config/facebook/config.authenticatorAsService.neon new file mode 100644 index 0000000..506f857 --- /dev/null +++ b/tests/Bridge/Nette/DI/config/facebook/config.authenticatorAsService.neon @@ -0,0 +1,13 @@ +extensions: + oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + oauth.facebook: SixtyEightPublishers\OAuth\Bridge\Nette\DI\FacebookOAuthExtension + +oauth.facebook: + config: + clientId: client + clientSecret: secret + graphApiVersion: v3.2 + authenticator: @azure_authenticator + +services: + azure_authenticator: SixtyEightPublishers\OAuth\Tests\Fixtures\AuthenticatorFixture diff --git a/tests/Bridge/Nette/DI/config/facebook/config.configAsService.neon b/tests/Bridge/Nette/DI/config/facebook/config.configAsService.neon new file mode 100644 index 0000000..fbfb060 --- /dev/null +++ b/tests/Bridge/Nette/DI/config/facebook/config.configAsService.neon @@ -0,0 +1,17 @@ +extensions: + oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + oauth.facebook: SixtyEightPublishers\OAuth\Bridge\Nette\DI\FacebookOAuthExtension + +oauth.facebook: + config: @azure_config + authenticator: SixtyEightPublishers\OAuth\Tests\Fixtures\AuthenticatorFixture + +services: + azure_config: SixtyEightPublishers\OAuth\Config\Config( + flowEnabled: true + options: [ + clientId: client + clientSecret: secret + graphApiVersion: v3.2 + ] + ) diff --git a/tests/Bridge/Nette/DI/config/facebook/config.configAsStatement.neon b/tests/Bridge/Nette/DI/config/facebook/config.configAsStatement.neon new file mode 100644 index 0000000..6effb49 --- /dev/null +++ b/tests/Bridge/Nette/DI/config/facebook/config.configAsStatement.neon @@ -0,0 +1,14 @@ +extensions: + oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + oauth.facebook: SixtyEightPublishers\OAuth\Bridge\Nette\DI\FacebookOAuthExtension + +oauth.facebook: + config: SixtyEightPublishers\OAuth\Config\Config( + flowEnabled: true + options: [ + clientId: client + clientSecret: secret + graphApiVersion: v3.2 + ] + ) + authenticator: SixtyEightPublishers\OAuth\Tests\Fixtures\AuthenticatorFixture diff --git a/tests/Bridge/Nette/DI/config/facebook/config.customFlowName.neon b/tests/Bridge/Nette/DI/config/facebook/config.customFlowName.neon new file mode 100644 index 0000000..ca91d31 --- /dev/null +++ b/tests/Bridge/Nette/DI/config/facebook/config.customFlowName.neon @@ -0,0 +1,11 @@ +extensions: + oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + oauth.facebook: SixtyEightPublishers\OAuth\Bridge\Nette\DI\FacebookOAuthExtension + +oauth.facebook: + flowName: custom_facebook + config: + clientId: client + clientSecret: secret + graphApiVersion: v3.2 + authenticator: SixtyEightPublishers\OAuth\Tests\Fixtures\AuthenticatorFixture diff --git a/tests/Bridge/Nette/DI/config/facebook/config.disabled.neon b/tests/Bridge/Nette/DI/config/facebook/config.disabled.neon new file mode 100644 index 0000000..f39ce7f --- /dev/null +++ b/tests/Bridge/Nette/DI/config/facebook/config.disabled.neon @@ -0,0 +1,11 @@ +extensions: + oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + oauth.facebook: SixtyEightPublishers\OAuth\Bridge\Nette\DI\FacebookOAuthExtension + +oauth.facebook: + config: + enabled: no + clientId: client + clientSecret: secret + graphApiVersion: v3.2 + authenticator: SixtyEightPublishers\OAuth\Tests\Fixtures\AuthenticatorFixture diff --git a/tests/Bridge/Nette/DI/config/facebook/config.minimal.neon b/tests/Bridge/Nette/DI/config/facebook/config.minimal.neon new file mode 100644 index 0000000..920beb9 --- /dev/null +++ b/tests/Bridge/Nette/DI/config/facebook/config.minimal.neon @@ -0,0 +1,10 @@ +extensions: + oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + oauth.facebook: SixtyEightPublishers\OAuth\Bridge\Nette\DI\FacebookOAuthExtension + +oauth.facebook: + config: + clientId: client + clientSecret: secret + graphApiVersion: v3.2 + authenticator: SixtyEightPublishers\OAuth\Tests\Fixtures\AuthenticatorFixture diff --git a/tests/Bridge/Nette/DI/config/oauth/config.neon b/tests/Bridge/Nette/DI/config/oauth/config.neon new file mode 100644 index 0000000..08b3a56 --- /dev/null +++ b/tests/Bridge/Nette/DI/config/oauth/config.neon @@ -0,0 +1,2 @@ +extensions: + oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension diff --git a/tests/Config/ConfigTest.phpt b/tests/Config/ConfigTest.phpt new file mode 100644 index 0000000..6f9bdde --- /dev/null +++ b/tests/Config/ConfigTest.phpt @@ -0,0 +1,85 @@ +isFlowEnabled()); + } + + public function testFlowIsDisabled(): void + { + $config = new Config( + flowEnabled: false, + options: [], + ); + + Assert::false($config->isFlowEnabled()); + } + + public function testConfigHaveOption(): void + { + $config = new Config( + flowEnabled: true, + options: [ + 'test' => '123', + ], + ); + + Assert::true($config->has('test')); + } + + public function testConfigDoesNotHaveOption(): void + { + $config = new Config( + flowEnabled: true, + options: [], + ); + + Assert::false($config->has('test')); + } + + public function testExceptionShouldBeThrownWhenOptionCanNotBeReturned(): void + { + $config = new Config( + flowEnabled: true, + options: [], + ); + + Assert::exception( + static fn () => $config->get('test'), + InvalidConfigurationException::class, + 'Missing configuration option "test".', + ); + } + + public function testOptionShouldBeReturned(): void + { + $config = new Config( + flowEnabled: true, + options: [ + 'test' => '123', + ], + ); + + Assert::same('123', $config->get('test')); + } +} + +(new ConfigTest())->run(); diff --git a/tests/Config/LazyConfigTest.phpt b/tests/Config/LazyConfigTest.phpt new file mode 100644 index 0000000..f256337 --- /dev/null +++ b/tests/Config/LazyConfigTest.phpt @@ -0,0 +1,122 @@ + '123', + ], + ); + }; + $config = new LazyConfig( + configFactory: $factory, + ); + + $config->isFlowEnabled(); + $config->has('test'); + $config->get('test'); + + Assert::same(1, $counter); + } + + public function testFlowIsEnabled(): void + { + $config = new LazyConfig( + configFactory: static fn (): Config => new Config( + flowEnabled: true, + options: [], + ), + ); + + Assert::true($config->isFlowEnabled()); + } + + public function testFlowIsDisabled(): void + { + $config = new LazyConfig( + configFactory: static fn (): Config => new Config( + flowEnabled: false, + options: [], + ), + ); + + Assert::false($config->isFlowEnabled()); + } + + public function testConfigHaveOption(): void + { + $config = new LazyConfig( + configFactory: static fn (): Config => new Config( + flowEnabled: true, + options: [ + 'test' => '123', + ], + ), + ); + + Assert::true($config->has('test')); + } + + public function testConfigDoesNotHaveOption(): void + { + $config = new LazyConfig( + configFactory: static fn (): Config => new Config( + flowEnabled: true, + options: [], + ), + ); + + Assert::false($config->has('test')); + } + + public function testExceptionShouldBeThrownWhenOptionCanNotBeReturned(): void + { + $config = new LazyConfig( + configFactory: static fn (): Config => new Config( + flowEnabled: true, + options: [], + ), + ); + + Assert::exception( + static fn () => $config->get('test'), + InvalidConfigurationException::class, + 'Missing configuration option "test".', + ); + } + + public function testOptionShouldBeReturned(): void + { + $config = new LazyConfig( + configFactory: static fn (): Config => new Config( + flowEnabled: true, + options: [ + 'test' => '123', + ], + ), + ); + + Assert::same('123', $config->get('test')); + } +} + +(new LazyConfigTest())->run(); diff --git a/tests/Fixtures/AuthenticatorFixture.php b/tests/Fixtures/AuthenticatorFixture.php new file mode 100644 index 0000000..1895504 --- /dev/null +++ b/tests/Fixtures/AuthenticatorFixture.php @@ -0,0 +1,26 @@ +identity) { + throw new AuthenticationException(); + } + + return $this->identity; + } +} diff --git a/tests/Fixtures/InMemoryUserStorage.php b/tests/Fixtures/InMemoryUserStorage.php new file mode 100644 index 0000000..b105f68 --- /dev/null +++ b/tests/Fixtures/InMemoryUserStorage.php @@ -0,0 +1,47 @@ +authenticated = true; + $this->reason = null; + $this->identity = $identity; + } + + public function clearAuthentication(bool $clearIdentity): void + { + $this->authenticated = false; + $this->reason = 1; + + if ($clearIdentity) { + $this->identity = null; + } + } + + public function getState(): array + { + return [ + $this->authenticated, + $this->identity, + $this->reason, + ]; + } + + public function setExpiration(?string $expire, bool $clearIdentity): void + { + } +} diff --git a/tests/Fixtures/OAuthFlowFixture.php b/tests/Fixtures/OAuthFlowFixture.php new file mode 100644 index 0000000..6b18bb7 --- /dev/null +++ b/tests/Fixtures/OAuthFlowFixture.php @@ -0,0 +1,56 @@ +name; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function getAuthorizationUrl(string $redirectUri, array $options = []): string + { + if (null === $this->getAuthorizationUrlHandler) { + throw new BadMethodCallException( + message: 'Handler for method getAuthorizationUrl() not provided.', + ); + } + + return ($this->getAuthorizationUrlHandler)($redirectUri, $options); + } + + public function run(array $parameters): IIdentity + { + if (null === $this->runHandler) { + throw new BadMethodCallException( + message: 'Handler for method run() not provided.', + ); + } + + return ($this->runHandler)($parameters); + } +} diff --git a/tests/OAuthFlowProviderTest.phpt b/tests/OAuthFlowProviderTest.phpt new file mode 100644 index 0000000..390334d --- /dev/null +++ b/tests/OAuthFlowProviderTest.phpt @@ -0,0 +1,90 @@ + $provider->get('test'), + MissingOAuthFlowException::class, + 'OAuth flow with the name "test" is missing.', + ); + } + + public function testExceptionShouldBeThrownOnMissingFlowService(): void + { + $provider = new OAuthFlowProvider( + container: new Container(), + flowServiceNames: [ + 'test' => 'flow_service.test', + ], + ); + + Assert::exception( + static fn () => $provider->get('test'), + MissingOAuthFlowException::class, + 'OAuth flow with the name "test" is missing.', + ); + } + + public function testFlowShouldBeReturned(): void + { + $container = new Container(); + $provider = new OAuthFlowProvider( + container: $container, + flowServiceNames: [ + 'test' => 'flow_service.test', + ], + ); + $flow = Mockery::mock(OAuthFlowInterface::class); + + $container->addService('flow_service.test', $flow); + + Assert::same($flow, $provider->get('test')); + } + + public function testAllProvidersShouldBeThrown(): void + { + $container = new Container(); + $provider = new OAuthFlowProvider( + container: $container, + flowServiceNames: [ + 'a' => 'flow_service.a', + 'b' => 'flow_service.b', + ], + ); + $flowA = Mockery::mock(OAuthFlowInterface::class); + $flowB = Mockery::mock(OAuthFlowInterface::class); + + $container->addService('flow_service.a', $flowA); + $container->addService('flow_service.b', $flowB); + + Assert::same([$flowA, $flowB], $provider->all()); + } + + protected function tearDown(): void + { + Mockery::close(); + } +} + +(new OAuthFlowProviderTest())->run(); diff --git a/tests/OAuthFlowTest.phpt b/tests/OAuthFlowTest.phpt new file mode 100644 index 0000000..b900660 --- /dev/null +++ b/tests/OAuthFlowTest.phpt @@ -0,0 +1,183 @@ +getName()); + } + + public function testFlowShouldBeEnabled(): void + { + $flow = new OAuthFlow( + name: 'test', + config: $config = Mockery::mock(ConfigInterface::class), + authorizator: Mockery::mock(AuthorizatorInterface::class), + authenticator: Mockery::mock(AuthenticatorInterface::class), + ); + + $config + ->shouldReceive('isFlowEnabled') + ->once() + ->andReturn(true); + + Assert::true($flow->isEnabled()); + } + + public function testFlowShouldBeDisabled(): void + { + $flow = new OAuthFlow( + name: 'test', + config: $config = Mockery::mock(ConfigInterface::class), + authorizator: Mockery::mock(AuthorizatorInterface::class), + authenticator: Mockery::mock(AuthenticatorInterface::class), + ); + + $config + ->shouldReceive('isFlowEnabled') + ->once() + ->andReturn(false); + + Assert::false($flow->isEnabled()); + } + + public function testExceptionShouldBeThrownIfFlowIsDisabledWhenReturningAuthorizationUrl(): void + { + $flow = new OAuthFlow( + name: 'test', + config: $config = Mockery::mock(ConfigInterface::class), + authorizator: Mockery::mock(AuthorizatorInterface::class), + authenticator: Mockery::mock(AuthenticatorInterface::class), + ); + + $config + ->shouldReceive('isFlowEnabled') + ->once() + ->andReturn(false); + + Assert::exception( + static fn () => $flow->getAuthorizationUrl('https://www.example.com'), + OAuthFlowIsDisabledException::class, + 'OAuth flow with the name "test" is disabled.', + ); + } + + public function testAuthorizationUrlShouldBeReturned(): void + { + $flow = new OAuthFlow( + name: 'test', + config: $config = Mockery::mock(ConfigInterface::class), + authorizator: $authorizator = Mockery::mock(AuthorizatorInterface::class), + authenticator: Mockery::mock(AuthenticatorInterface::class), + ); + $redirectUrl = 'https://www.example.com'; + $authorizationUrl = 'https://oauth.service.com'; + $options = [ + 'state' => '__state__', + ]; + + $config + ->shouldReceive('isFlowEnabled') + ->once() + ->andReturn(true); + + $authorizator + ->shouldReceive('getAuthorizationUrl') + ->once() + ->with($config, $redirectUrl, $options) + ->andReturn($authorizationUrl); + + Assert::same($authorizationUrl, $flow->getAuthorizationUrl($redirectUrl, $options)); + } + + public function testExceptionShouldBeThrownIfFlowIsDisabledWhenFlowRun(): void + { + $flow = new OAuthFlow( + name: 'test', + config: $config = Mockery::mock(ConfigInterface::class), + authorizator: Mockery::mock(AuthorizatorInterface::class), + authenticator: Mockery::mock(AuthenticatorInterface::class), + ); + + $config + ->shouldReceive('isFlowEnabled') + ->once() + ->andReturn(false); + + Assert::exception( + static fn () => $flow->run([]), + OAuthFlowIsDisabledException::class, + 'OAuth flow with the name "test" is disabled.', + ); + } + + public function testFlowShouldRun(): void + { + $flow = new OAuthFlow( + name: 'test', + config: $config = Mockery::mock(ConfigInterface::class), + authorizator: $authorizator = Mockery::mock(AuthorizatorInterface::class), + authenticator: $authenticator = Mockery::mock(AuthenticatorInterface::class), + ); + $parameters = [ + 'code' => '__code__', + ]; + $authorizationResult = new AuthorizationResult( + resourceOwner: Mockery::mock(ResourceOwnerInterface::class), + accessToken: Mockery::mock(AccessTokenInterface::class), + ); + $identity = Mockery::mock(IIdentity::class); + + $config + ->shouldReceive('isFlowEnabled') + ->once() + ->andReturn(true); + + $authorizator + ->shouldReceive('authorize') + ->once() + ->with($config, $parameters) + ->andReturn($authorizationResult); + + $authenticator + ->shouldReceive('authenticate') + ->once() + ->with('test', $authorizationResult) + ->andReturn($identity); + + Assert::same($identity, $flow->run($parameters)); + } + + protected function tearDown(): void + { + Mockery::close(); + } +} + +(new OAuthFlowTest())->run(); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..032199c --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,12 @@ +