├── .github ├── .kodiak.toml └── workflows │ └── main.yaml ├── LICENSE ├── composer.json └── src ├── DI ├── FacebookAuthExtension.php ├── GitlabAuthExtension.php └── GoogleAuthExtension.php ├── Exception ├── Logical │ └── InvalidArgumentException.php ├── LogicalException.php ├── Runtime │ ├── CannotAuthenticateUserException.php │ ├── PossibleCsrfAttackException.php │ └── UserProbablyDeniedAccessException.php └── RuntimeException.php ├── Flow ├── AuthCodeFlow.php ├── Facebook │ ├── FacebookAuthCodeFlow.php │ └── FacebookProvider.php ├── Gitlab │ ├── GitlabAuthCodeFlow.php │ └── GitlabProvider.php └── Google │ ├── GoogleAuthCodeFlow.php │ └── GoogleProvider.php └── UI └── Components ├── GenericAuthControl.latte └── GenericAuthControl.php /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [merge] 4 | automerge_label = "automerge" 5 | blacklist_title_regex = "^WIP.*" 6 | blacklist_labels = ["WIP"] 7 | method = "rebase" 8 | delete_branch_on_merge = true 9 | notify_on_conflict = true 10 | optimistic_updates = false 11 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: "build" 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - ".docs/**" 7 | push: 8 | branches: 9 | - "*" 10 | schedule: 11 | - cron: "0 8 * * 1" # At 08:00 on Monday 12 | 13 | env: 14 | extensions: "json" 15 | cache-version: "1" 16 | composer-version: "v2" 17 | composer-install: "composer update --no-interaction --no-progress --prefer-dist --prefer-stable" 18 | coverage: "none" 19 | 20 | jobs: 21 | qa: 22 | name: "Quality assurance" 23 | runs-on: "${{ matrix.operating-system }}" 24 | 25 | strategy: 26 | matrix: 27 | php-version: [ "8.1" ] 28 | operating-system: [ "ubuntu-latest" ] 29 | fail-fast: false 30 | 31 | steps: 32 | - name: "Checkout" 33 | uses: "actions/checkout@v2" 34 | 35 | - name: "Setup PHP cache environment" 36 | id: "extcache" 37 | uses: "shivammathur/cache-extensions@v1" 38 | with: 39 | php-version: "${{ matrix.php-version }}" 40 | extensions: "${{ env.extensions }}" 41 | key: "${{ env.cache-version }}" 42 | 43 | - name: "Cache PHP extensions" 44 | uses: "actions/cache@v2" 45 | with: 46 | path: "${{ steps.extcache.outputs.dir }}" 47 | key: "${{ steps.extcache.outputs.key }}" 48 | restore-keys: "${{ steps.extcache.outputs.key }}" 49 | 50 | - name: "Install PHP" 51 | uses: "shivammathur/setup-php@v2" 52 | with: 53 | php-version: "${{ matrix.php-version }}" 54 | extensions: "${{ env.extensions }}" 55 | tools: "composer:${{ env.composer-version }}, cs2pr" 56 | coverage: "${{ env.coverage }}" 57 | env: 58 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | 60 | - name: "Setup problem matchers for PHP" 61 | run: 'echo "::add-matcher::${{ runner.tool_cache }}/php.json"' 62 | 63 | - name: "Get Composer cache directory" 64 | id: "composercache" 65 | run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"' 66 | 67 | - name: "Cache PHP dependencies" 68 | uses: "actions/cache@v2" 69 | with: 70 | path: "${{ steps.composercache.outputs.dir }}" 71 | key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}" 72 | restore-keys: "${{ runner.os }}-composer-" 73 | 74 | - name: "Validate Composer" 75 | run: "composer validate" 76 | 77 | - name: "Install dependencies" 78 | run: "${{ env.composer-install }}" 79 | 80 | - name: "Coding Standard" 81 | run: "make cs" 82 | 83 | static-analysis: 84 | name: "Static analysis" 85 | runs-on: "${{ matrix.operating-system }}" 86 | 87 | strategy: 88 | matrix: 89 | php-version: [ "7.4", "8.1" ] 90 | operating-system: [ "ubuntu-latest" ] 91 | fail-fast: false 92 | 93 | steps: 94 | - name: "Checkout" 95 | uses: "actions/checkout@v2" 96 | 97 | - name: "Setup PHP cache environment" 98 | id: "extcache" 99 | uses: "shivammathur/cache-extensions@v1" 100 | with: 101 | php-version: "${{ matrix.php-version }}" 102 | extensions: "${{ env.extensions }}" 103 | key: "${{ env.cache-version }}" 104 | 105 | - name: "Cache PHP extensions" 106 | uses: "actions/cache@v2" 107 | with: 108 | path: "${{ steps.extcache.outputs.dir }}" 109 | key: "${{ steps.extcache.outputs.key }}" 110 | restore-keys: "${{ steps.extcache.outputs.key }}" 111 | 112 | - name: "Install PHP" 113 | uses: "shivammathur/setup-php@v2" 114 | with: 115 | php-version: "${{ matrix.php-version }}" 116 | extensions: "${{ env.extensions }}" 117 | tools: "composer:${{ env.composer-version }}" 118 | coverage: "${{ env.coverage }}" 119 | env: 120 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 121 | 122 | - name: "Setup problem matchers for PHP" 123 | run: 'echo "::add-matcher::${{ runner.tool_cache }}/php.json"' 124 | 125 | - name: "Get Composer cache directory" 126 | id: "composercache" 127 | run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"' 128 | 129 | - name: "Cache PHP dependencies" 130 | uses: "actions/cache@v2" 131 | with: 132 | path: "${{ steps.composercache.outputs.dir }}" 133 | key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}" 134 | restore-keys: "${{ runner.os }}-composer-" 135 | 136 | - name: "Install dependencies" 137 | run: "${{ env.composer-install }}" 138 | 139 | - name: "PHPStan" 140 | run: "make phpstan" 141 | 142 | tests: 143 | name: "Tests" 144 | runs-on: "${{ matrix.operating-system }}" 145 | 146 | strategy: 147 | matrix: 148 | php-version: [ "7.2", "7.3", "7.4", "8.0", "8.1" ] 149 | operating-system: [ "ubuntu-latest" ] 150 | composer-args: [ "" ] 151 | include: 152 | - php-version: "7.2" 153 | operating-system: "ubuntu-latest" 154 | composer-args: "--prefer-lowest" 155 | fail-fast: false 156 | 157 | steps: 158 | - name: "Checkout" 159 | uses: "actions/checkout@v2" 160 | 161 | - name: "Setup PHP cache environment" 162 | id: "extcache" 163 | uses: "shivammathur/cache-extensions@v1" 164 | with: 165 | php-version: "${{ matrix.php-version }}" 166 | extensions: "${{ env.extensions }}" 167 | key: "${{ env.cache-version }}" 168 | 169 | - name: "Cache PHP extensions" 170 | uses: "actions/cache@v2" 171 | with: 172 | path: "${{ steps.extcache.outputs.dir }}" 173 | key: "${{ steps.extcache.outputs.key }}" 174 | restore-keys: "${{ steps.extcache.outputs.key }}" 175 | 176 | - name: "Install PHP" 177 | uses: "shivammathur/setup-php@v2" 178 | with: 179 | php-version: "${{ matrix.php-version }}" 180 | extensions: "${{ env.extensions }}" 181 | tools: "composer:${{ env.composer-version }}" 182 | coverage: "${{ env.coverage }}" 183 | env: 184 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 185 | 186 | - name: "Setup problem matchers for PHP" 187 | run: 'echo "::add-matcher::${{ runner.tool_cache }}/php.json"' 188 | 189 | - name: "Get Composer cache directory" 190 | id: "composercache" 191 | run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"' 192 | 193 | - name: "Cache PHP dependencies" 194 | uses: "actions/cache@v2" 195 | with: 196 | path: "${{ steps.composercache.outputs.dir }}" 197 | key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}" 198 | restore-keys: "${{ runner.os }}-composer-" 199 | 200 | - name: "Install dependencies" 201 | run: "${{ env.composer-install }} ${{ matrix.composer-args }}" 202 | 203 | - name: "Tests" 204 | run: "make tests" 205 | 206 | - name: "Upload test output" 207 | if: ${{ failure() }} 208 | uses: actions/upload-artifact@v2 209 | with: 210 | name: output 211 | path: tests/**/output 212 | 213 | tests-code-coverage: 214 | name: "Tests with code coverage" 215 | runs-on: "${{ matrix.operating-system }}" 216 | 217 | strategy: 218 | matrix: 219 | php-version: [ "7.4" ] 220 | operating-system: [ "ubuntu-latest" ] 221 | fail-fast: false 222 | 223 | if: "github.event_name == 'push'" 224 | 225 | steps: 226 | - name: "Checkout" 227 | uses: "actions/checkout@v2" 228 | 229 | - name: "Setup PHP cache environment" 230 | id: "extcache" 231 | uses: "shivammathur/cache-extensions@v1" 232 | with: 233 | php-version: "${{ matrix.php-version }}" 234 | extensions: "${{ env.extensions }}" 235 | key: "${{ env.cache-version }}" 236 | 237 | - name: "Cache PHP extensions" 238 | uses: "actions/cache@v2" 239 | with: 240 | path: "${{ steps.extcache.outputs.dir }}" 241 | key: "${{ steps.extcache.outputs.key }}" 242 | restore-keys: "${{ steps.extcache.outputs.key }}" 243 | 244 | - name: "Install PHP" 245 | uses: "shivammathur/setup-php@v2" 246 | with: 247 | php-version: "${{ matrix.php-version }}" 248 | extensions: "${{ env.extensions }}" 249 | tools: "composer:${{ env.composer-version }}" 250 | coverage: "${{ env.coverage }}" 251 | env: 252 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 253 | 254 | - name: "Setup problem matchers for PHP" 255 | run: 'echo "::add-matcher::${{ runner.tool_cache }}/php.json"' 256 | 257 | - name: "Get Composer cache directory" 258 | id: "composercache" 259 | run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"' 260 | 261 | - name: "Cache PHP dependencies" 262 | uses: "actions/cache@v2" 263 | with: 264 | path: "${{ steps.composercache.outputs.dir }}" 265 | key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}" 266 | restore-keys: "${{ runner.os }}-composer-" 267 | 268 | - name: "Install dependencies" 269 | run: "${{ env.composer-install }}" 270 | 271 | - name: "Tests" 272 | run: "make coverage-clover" 273 | 274 | - name: "Upload test output" 275 | if: ${{ failure() }} 276 | uses: actions/upload-artifact@v2 277 | with: 278 | name: output 279 | path: tests/**/output 280 | 281 | - name: "Coveralls.io" 282 | env: 283 | CI_NAME: github 284 | CI: true 285 | COVERALLS_REPO_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 286 | run: | 287 | wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.1.0/php-coveralls.phar 288 | php php-coveralls.phar --verbose --config tests/.coveralls.yml 289 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Contributte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contributte/oauth2-client", 3 | "description": "Integration of league/oauth2-client into Nette framework", 4 | "keywords": [ 5 | "nette", 6 | "oauth2", 7 | "client" 8 | ], 9 | "type": "library", 10 | "license": "MIT", 11 | "homepage": "https://github.com/contributte/oauth2-client", 12 | "authors": [ 13 | { 14 | "name": "Milan Felix Šulc", 15 | "homepage": "https://f3l1x.io" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=7.2", 20 | "league/oauth2-client": "^2.6.0", 21 | "nette/http": "^3.0.5" 22 | }, 23 | "require-dev": { 24 | "nette/di": "^3.0.0", 25 | "nette/application": "^3.1.0", 26 | "league/oauth2-facebook": "^2.0.5", 27 | "league/oauth2-google": "^3.0.3", 28 | "mockery/mockery": "^1.3.3", 29 | "ninjify/qa": "^0.12", 30 | "ninjify/nunjuck": "^0.4", 31 | "phpstan/phpstan": "^1.0", 32 | "phpstan/phpstan-deprecation-rules": "^1.0", 33 | "phpstan/phpstan-nette": "^1.0", 34 | "phpstan/phpstan-strict-rules": "^1.0" 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "Contributte\\OAuth2Client\\": "src" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Tests\\Cases\\": "tests/cases", 44 | "Tests\\Fixtures\\": "tests/fixtures" 45 | } 46 | }, 47 | "minimum-stability": "dev", 48 | "prefer-stable": true, 49 | "config": { 50 | "sort-packages": true, 51 | "allow-plugins": { 52 | "dealerdirect/phpcodesniffer-composer-installer": true 53 | } 54 | }, 55 | "extra": { 56 | "branch-alias": { 57 | "dev-develop": "0.3.x-dev" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/DI/FacebookAuthExtension.php: -------------------------------------------------------------------------------- 1 | Expect::string()->required(), 22 | 'clientSecret' => Expect::string()->required(), 23 | 'graphApiVersion' => Expect::string()->required(), 24 | 'options' => Expect::array(), 25 | ]); 26 | } 27 | 28 | public function loadConfiguration(): void 29 | { 30 | $builder = $this->getContainerBuilder(); 31 | $config = $this->config; 32 | 33 | $providerOptions = [ 34 | 'clientId' => $config->clientId, 35 | 'clientSecret' => $config->clientSecret, 36 | 'graphApiVersion' => $config->graphApiVersion, 37 | ]; 38 | 39 | if (isset($config->options)) { 40 | $providerOptions = array_merge($config->options, $providerOptions); 41 | } 42 | 43 | $builder->addDefinition($this->prefix('provider')) 44 | ->setFactory(FacebookProvider::class, [$providerOptions]); 45 | 46 | $builder->addDefinition($this->prefix('authCodeFlow')) 47 | ->setFactory(FacebookAuthCodeFlow::class, ['@' . $this->prefix('provider')]); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/DI/GitlabAuthExtension.php: -------------------------------------------------------------------------------- 1 | Expect::string()->required(), 22 | 'clientSecret' => Expect::string()->required(), 23 | 'domain' => Expect::string()->required(), 24 | 'options' => Expect::array(), 25 | ]); 26 | } 27 | 28 | public function loadConfiguration(): void 29 | { 30 | $builder = $this->getContainerBuilder(); 31 | $config = $this->config; 32 | 33 | $providerOptions = [ 34 | 'clientId' => $config->clientId, 35 | 'clientSecret' => $config->clientSecret, 36 | 'domain' => $config->domain, 37 | ]; 38 | 39 | if (isset($config->options)) { 40 | $providerOptions = array_merge($config->options, $providerOptions); 41 | } 42 | 43 | $builder->addDefinition($this->prefix('provider')) 44 | ->setFactory(GitlabProvider::class, [$providerOptions]); 45 | 46 | $builder->addDefinition($this->prefix('authCodeFlow')) 47 | ->setFactory(GitlabAuthCodeFlow::class, ['@' . $this->prefix('provider')]); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/DI/GoogleAuthExtension.php: -------------------------------------------------------------------------------- 1 | Expect::string()->required(), 22 | 'clientSecret' => Expect::string()->required(), 23 | 'options' => Expect::array(), 24 | ]); 25 | } 26 | 27 | public function loadConfiguration(): void 28 | { 29 | $builder = $this->getContainerBuilder(); 30 | $config = $this->config; 31 | 32 | $providerOptions = [ 33 | 'clientId' => $config->clientId, 34 | 'clientSecret' => $config->clientSecret, 35 | ]; 36 | 37 | if (isset($config->options)) { 38 | $providerOptions = array_merge($config->options, $providerOptions); 39 | } 40 | 41 | $builder->addDefinition($this->prefix('provider')) 42 | ->setFactory(GoogleProvider::class, [$providerOptions]); 43 | 44 | $builder->addDefinition($this->prefix('authCodeFlow')) 45 | ->setFactory(GoogleAuthCodeFlow::class, ['@' . $this->prefix('provider')]); 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/Exception/Logical/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | provider = $provider; 28 | $this->session = $session; 29 | } 30 | 31 | /** 32 | * @param string|mixed[]|null $redirectUriOrOptions 33 | * @param mixed[] $options 34 | */ 35 | public function getAuthorizationUrl($redirectUriOrOptions = null, array $options = []): string 36 | { 37 | if (is_array($redirectUriOrOptions)) { 38 | $options = array_merge($options, $redirectUriOrOptions); 39 | } elseif (is_string($redirectUriOrOptions)) { 40 | $options['redirect_uri'] = $redirectUriOrOptions; 41 | } elseif ($redirectUriOrOptions !== null) { /** @phpstan-ignore-line */ 42 | throw new RuntimeException('Parameter #1 redirectUriOrOptions of getAuthorizationUrl accepts only string or array.'); 43 | } 44 | 45 | $session = $this->session->getSection(self::SESSION_NAMESPACE); 46 | 47 | $url = $this->provider->getAuthorizationUrl($options); 48 | 49 | $session['state'] = $this->provider->getState(); 50 | $session['redirect_uri'] = $options['redirect_uri'] ?? null; 51 | 52 | return $url; 53 | } 54 | 55 | /** 56 | * @param mixed[] $parameters 57 | * @return AccessToken|AccessTokenInterface 58 | * @throws IdentityProviderException 59 | */ 60 | public function getAccessToken(array $parameters, ?string $redirectUri = null): AccessTokenInterface 61 | { 62 | if (!isset($parameters['code'])) { 63 | throw new InvalidArgumentException('Missing "code" parameter'); 64 | } 65 | 66 | if (!isset($parameters['state'])) { 67 | throw new InvalidArgumentException('Missing "state" parameter'); 68 | } 69 | 70 | $session = $this->session->getSection(self::SESSION_NAMESPACE); 71 | 72 | // Possible CSRF attack 73 | if (isset($session['state']) && $parameters['state'] !== $session['state']) { 74 | unset($session['state']); 75 | unset($session['redirect_uri']); 76 | throw new PossibleCsrfAttackException(); 77 | } 78 | 79 | $options = array_filter([ 80 | 'code' => $parameters['code'], 81 | 'redirect_uri' => $redirectUri ?? $session['redirect_uri'] ?? null, 82 | ]); 83 | 84 | // Try to get an access token (using the authorization code grant) 85 | return $this->provider->getAccessToken('authorization_code', $options); 86 | } 87 | 88 | public function getProvider(): AbstractProvider 89 | { 90 | return $this->provider; 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /src/Flow/Facebook/FacebookAuthCodeFlow.php: -------------------------------------------------------------------------------- 1 | redirectUri = $uri; 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/Flow/Gitlab/GitlabAuthCodeFlow.php: -------------------------------------------------------------------------------- 1 | redirectUri = $uri; 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/Flow/Google/GoogleAuthCodeFlow.php: -------------------------------------------------------------------------------- 1 | redirectUri = $uri; 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/UI/Components/GenericAuthControl.latte: -------------------------------------------------------------------------------- 1 | Login 2 | -------------------------------------------------------------------------------- /src/UI/Components/GenericAuthControl.php: -------------------------------------------------------------------------------- 1 | */ 26 | public $onAuthenticated = []; 27 | 28 | /** @var array */ 29 | public $onFailed = []; 30 | 31 | public function __construct(AuthCodeFlow $authCodeFlow, ?string $redirectUri = null) 32 | { 33 | $this->authCodeFlow = $authCodeFlow; 34 | $this->redirectUri = $redirectUri; 35 | } 36 | 37 | public function setTemplate(string $templatePath): void 38 | { 39 | $this->templatePath = $templatePath; 40 | } 41 | 42 | public function handleAuthenticate(): void 43 | { 44 | $this->authenticate(); 45 | } 46 | 47 | public function authenticate(): void 48 | { 49 | $this->getPresenter()->redirectUrl( 50 | $this->authCodeFlow->getAuthorizationUrl(['redirect_uri' => $this->redirectUri]) 51 | ); 52 | } 53 | 54 | public function authorize(): ?ResourceOwnerInterface 55 | { 56 | try { 57 | $accessToken = $this->authCodeFlow->getAccessToken($this->getPresenter()->getHttpRequest()->getQuery()); 58 | if (!$accessToken instanceof AccessToken) { 59 | throw new UnexpectedValueException(); 60 | } 61 | 62 | $user = $this->authCodeFlow->getProvider()->getResourceOwner($accessToken); 63 | $this->authenticationSucceed($accessToken, $user); 64 | return $user; 65 | } catch (IdentityProviderException $e) { 66 | $this->authenticationFailed(); 67 | } 68 | 69 | return null; 70 | } 71 | 72 | protected function authenticationFailed(): void 73 | { 74 | $this->onFailed(); 75 | } 76 | 77 | protected function authenticationSucceed(AccessToken $accessToken, ResourceOwnerInterface $user): void 78 | { 79 | $this->onAuthenticated($accessToken, $user); 80 | } 81 | 82 | public function render(): void 83 | { 84 | $template = $this->getTemplate(); 85 | if (!$template instanceof Template) { 86 | throw new UnexpectedValueException(); 87 | } 88 | 89 | $template->render($this->templatePath ?? __DIR__ . '/GenericAuthControl.latte'); 90 | } 91 | 92 | } 93 | --------------------------------------------------------------------------------