├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .php-cs-fixer.php ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bin ├── install-infection └── prepare-commit ├── composer.json ├── infection.json5 ├── phpstan.neon ├── phpunit.xml.dist ├── src └── Provider │ ├── Exception │ └── GitlabIdentityProviderException.php │ ├── Gitlab.php │ └── GitlabResourceOwner.php └── test └── src └── Provider └── GitlabTest.php /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: test suite 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | schedule: # Ensure weekly test also if no pushes happen to keep up with dependencies 9 | - cron: 0 11 * * 1 10 | 11 | jobs: 12 | run: 13 | runs-on: ubuntu-latest 14 | continue-on-error: ${{ matrix.experimental || matrix.dependencies == 'beta' }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | php: 19 | - '8.1' 20 | - '8.2' 21 | - '8.3' 22 | - '8.4' 23 | dependencies: [ stable, beta, lowest ] 24 | experimental: [ false ] 25 | include: 26 | - description: 'stable dependencies' 27 | dependencies: stable 28 | - description: 'lowest dependencies' 29 | dependencies: lowest 30 | - description: 'beta/RC dependencies' 31 | dependencies: beta 32 | # - description: 'nightly with stable dependencies' 33 | # php: 8.5 34 | # experimental: true 35 | 36 | name: PHP ${{ matrix.php }} ${{ matrix.description }} 37 | steps: 38 | - name: Checkout 39 | uses: actions/checkout@v4 40 | 41 | - name: Setup PHP 42 | uses: shivammathur/setup-php@v2 43 | with: 44 | php-version: ${{ matrix.php }} 45 | extensions: intl, mbstring 46 | ini-values: zend.assertions=1 47 | 48 | - name: Dump PHP diagnostics 49 | run: php -i && php -m 50 | 51 | - name: Allow beta dependencies 52 | run: composer config minimum-stability beta 53 | if: matrix.dependencies == 'beta' 54 | 55 | - name: Install dependencies 56 | run: composer update --no-progress ${{ (matrix.dependencies == 'lowest') && '--prefer-lowest --prefer-stable' || ''}} 57 | 58 | - name: Check code style 59 | run: vendor/bin/php-cs-fixer fix --dry-run 60 | if: ${{ matrix.dependencies == 'stable' && !matrix.experimental }} 61 | env: 62 | PHP_CS_FIXER_IGNORE_ENV: 1 63 | 64 | - name: Run PHPStan static analysis 65 | run: vendor/bin/phpstan 66 | if: ${{ matrix.dependencies != 'lowest' && !matrix.experimental }} 67 | 68 | - name: Run automated tests 69 | run: vendor/bin/phpunit --coverage-text --coverage-xml build/coverage-xml --coverage-cobertura build/cobertura.xml 70 | 71 | - name: Upload coverage reports to Codecov 72 | uses: codecov/codecov-action@v3.1.5 73 | env: 74 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 75 | 76 | - name: Run infection tests 77 | run: vendor/bin/infection --threads=max 78 | if: ${{ matrix.dependencies == 'stable' && !matrix.experimental }} 79 | env: 80 | STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_TOKEN }} 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /log 3 | /vendor 4 | /.idea 5 | .*.cache 6 | composer.phar 7 | composer.lock 8 | infection.json 9 | infection.phar* 10 | phpunit.xml -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | files() 13 | ->name('*.php') 14 | ->in(__DIR__.'/src') 15 | ->in(__DIR__.'/test') 16 | ; 17 | 18 | $config = new PhpCsFixer\Config(); 19 | return $config 20 | // ->setRiskyAllowed(true) 21 | ->setRules([ 22 | '@Symfony' => true, 23 | 'concat_space' => ['spacing' => 'one'], 24 | 'header_comment' => ['header' => $header, 'location' => 'after_open'], 25 | 26 | // 'mb_str_functions' => true, 27 | 'ordered_imports' => true, 28 | 'phpdoc_align' => false, 29 | 'phpdoc_separation' => false, 30 | 'phpdoc_var_without_name' => false, 31 | ]) 32 | ->setFinder($finder) 33 | ; 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to `oauth2-gitlab` will be documented in this file 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## [Unreleased] 6 | Nothing yet. 7 | 8 | ## [3.6.0] - 2023-11-06 9 | Maintenance release 10 | 11 | - Upped code quality to PHPStan max level 12 | - Officially support PHP 8.3 13 | - Switched to Github Actions 14 | 15 | ## [3.5.0] - 2022-10-18 16 | ### Changed 17 | - Maintenance release dropping support for PHP versions below 8.0 18 | 19 | ## [3.4.0] - 2021-02-08 20 | ### Added 21 | - Compatibility with php-gitlab-api v11 22 | - Test suite compatible with PHP8 23 | 24 | ## [3.3.0] - 2020-02-10 25 | ### Added 26 | - Compatibility with php-gitlab-api v10 27 | 28 | ## [3.2.0] - 2020-02-10 29 | ### Changed 30 | - Updated dependencies to those requiring up to date PHP versions 31 | 32 | ### Removed 33 | - Support for outdated and unsupported PHP versions (<7.2) 34 | 35 | ## [3.1.2] - 2018-11-23 36 | ### Changed 37 | - Added conflict with `oauth2-client:2.4.0` due to [breaking change upstream](https://github.com/thephpleague/oauth2-client/issues/752) (#6) 38 | 39 | ## [3.1.1] - 2018-10-01 40 | ### Added 41 | - PHP 7.2 and nightly added to test suite 42 | - Infection testing added 43 | 44 | ### Changed 45 | - Test suite upgraded to PHPUnit 5/7 hybrid 46 | 47 | ## [3.1.0] - 2017-11-01 48 | ### Added 49 | - Access scope support was implemented 50 | 51 | ## [3.0.0] - 2017-05-31 52 | ### Changed 53 | - **Breaking**: Upgrade Gitlab API from v3 to v4 54 | - Test suite upgraded from PHPUnit 4 to 5/6 hybrid 55 | 56 | ## [2.0.0] - 2017-02-03 57 | ### Added 58 | - PHP 7.1 is now officially supported and tested 59 | 60 | ### Changed 61 | - **Breaking**: Upgrade league/oauth2-client to major version 2 62 | - Included PHP-CS-Fixer 63 | 64 | ### Removed 65 | - PHP 5.5 is end of life and no longer supported 66 | 67 | ## [1.1.0] - 2016-08-28 68 | ### Added 69 | - Added `getApiClient` method on `GitlabResourceOwner` to get an API connector 70 | 71 | ## [1.0.0] - 2016-05-20 72 | ### Changed 73 | - Cleaned up everything after definitive testing for stable release 74 | 75 | ## 1.0.0-alpha-1 - 2016-05-16 76 | ### Added 77 | - Original fork, feature complete 78 | 79 | [Unreleased]: https://github.com/omines/oauth2-gitlab/compare/3.6.0...master 80 | [3.6.0]: https://github.com/omines/oauth2-gitlab/compare/3.5.0...3.6.0 81 | [3.5.0]: https://github.com/omines/oauth2-gitlab/compare/3.4.0...3.5.0 82 | [3.4.0]: https://github.com/omines/oauth2-gitlab/compare/3.3.0...3.4.0 83 | [3.3.0]: https://github.com/omines/oauth2-gitlab/compare/3.2.0...3.3.0 84 | [3.2.0]: https://github.com/omines/oauth2-gitlab/compare/3.1.2...3.2.0 85 | [3.1.2]: https://github.com/omines/oauth2-gitlab/compare/3.1.1...3.1.2 86 | [3.1.1]: https://github.com/omines/oauth2-gitlab/compare/3.1.0...3.1.1 87 | [3.1.0]: https://github.com/omines/oauth2-gitlab/compare/3.0.0...3.1.0 88 | [3.0.0]: https://github.com/omines/oauth2-gitlab/compare/2.0.0...3.0.0 89 | [2.0.0]: https://github.com/omines/oauth2-gitlab/compare/1.1.0...2.0.0 90 | [1.1.0]: https://github.com/omines/oauth2-gitlab/compare/1.0.0...1.1.0 91 | [1.0.0]: https://github.com/omines/oauth2-gitlab/compare/1.0.0-alpha.1...1.0.0 92 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/omines/oauth2-gitlab). Follow 6 | [good standards](http://www.phptherightway.com/), keep code coverage at 100%, and run `bin/prepare-commit` 7 | before committing. 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 and beyond Omines Internetbureau B.V. / Steven Maguire 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitLab Provider for OAuth 2.0 Client 2 | [![Latest Version](https://img.shields.io/github/release/omines/oauth2-gitlab.svg?style=flat-square)](https://github.com/omines/oauth2-gitlab/releases) 3 | [![Total Downloads](https://img.shields.io/packagist/dt/omines/oauth2-gitlab.svg?style=flat-square)](https://packagist.org/packages/omines/oauth2-gitlab) 4 | [![test suite](https://github.com/omines/oauth2-gitlab/actions/workflows/ci.yaml/badge.svg)](https://github.com/omines/oauth2-gitlab/actions/workflows/ci.yaml) 5 | [![codecov](https://codecov.io/gh/omines/oauth2-gitlab/graph/badge.svg?token=sAqu9IFaYQ)](https://codecov.io/gh/omines/oauth2-gitlab) 6 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fomines%2Foauth2-gitlab%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/omines/oauth2-gitlab/master) 7 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 8 | 9 | This package provides GitLab OAuth 2.0 support for the PHP League's [OAuth 2.0 Client](https://github.com/thephpleague/oauth2-client). 10 | 11 | ## Installation 12 | 13 | To install, use composer: 14 | 15 | ``` 16 | composer require omines/oauth2-gitlab 17 | ``` 18 | 19 | ## Usage 20 | 21 | Usage is similar to the basic OAuth client, using `\Omines\OAuth2\Client\Provider\Gitlab` as the provider. 22 | 23 | ### Authorization Code Flow 24 | 25 | ```php 26 | $provider = new \Omines\OAuth2\Client\Provider\Gitlab([ 27 | 'clientId' => '{gitlab-client-id}', 28 | 'clientSecret' => '{gitlab-client-secret}', 29 | 'redirectUri' => 'https://example.com/callback-url', 30 | 'domain' => 'https://my.gitlab.example', // Optional base URL for self-hosted 31 | ]); 32 | 33 | if (!isset($_GET['code'])) { 34 | 35 | // If we don't have an authorization code then get one 36 | $authUrl = $provider->getAuthorizationUrl(); 37 | $_SESSION['oauth2state'] = $provider->getState(); 38 | header('Location: '.$authUrl); 39 | exit; 40 | 41 | // Check given state against previously stored one to mitigate CSRF attack 42 | } elseif (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) { 43 | 44 | unset($_SESSION['oauth2state']); 45 | exit('Invalid state'); 46 | 47 | } else { 48 | 49 | // Try to get an access token (using the authorization code grant) 50 | $token = $provider->getAccessToken('authorization_code', [ 51 | 'code' => $_GET['code'], 52 | ]); 53 | 54 | // Optional: Now you have a token you can look up a users profile data 55 | try { 56 | 57 | // We got an access token, let's now get the user's details 58 | $user = $provider->getResourceOwner($token); 59 | 60 | // Use these details to create a new profile 61 | printf('Hello %s!', $user->getName()); 62 | 63 | } catch (Exception $e) { 64 | 65 | // Failed to get user details 66 | exit('Oh dear...'); 67 | } 68 | 69 | // Use this to interact with an API on the users behalf 70 | echo $token->getToken(); 71 | } 72 | ``` 73 | 74 | ### Managing Scopes 75 | 76 | When creating your GitLab authorization URL, you can specify the state and scopes your application may authorize. 77 | 78 | ```php 79 | $options = [ 80 | 'state' => 'OPTIONAL_CUSTOM_CONFIGURED_STATE', 81 | 'scope' => ['read_user','openid'] // array or string 82 | ]; 83 | 84 | $authorizationUrl = $provider->getAuthorizationUrl($options); 85 | ``` 86 | If neither are defined, the provider will utilize internal defaults ```'api'```. 87 | 88 | 89 | ### Performing API calls 90 | 91 | Install [`m4tthumphrey/php-gitlab-api`](https://packagist.org/packages/m4tthumphrey/php-gitlab-api) to interact with the 92 | Gitlab API after authentication. Either connect manually: 93 | 94 | ```php 95 | $client = new \Gitlab\Client(); 96 | $client->setUrl('https://my.gitlab.url/api/v4/'); 97 | $client->authenticate($token->getToken(), \Gitlab\Client::AUTH_OAUTH_TOKEN); 98 | ``` 99 | Or call the `getApiClient` method on `GitlabResourceOwner` which does the same implicitly. 100 | 101 | ## Contributing 102 | 103 | Please see [CONTRIBUTING](https://github.com/omines/oauth2-gitlab/blob/master/CONTRIBUTING.md) for details. 104 | 105 | ## Credits 106 | 107 | This code is a modified fork from the [official Github provider](https://github.com/thephpleague/oauth2-github) adapted 108 | for Gitlab use, so many credits go to [Steven Maguire](https://github.com/stevenmaguire). 109 | 110 | ## Legal 111 | 112 | This software was developed for internal use at [Omines Full Service Internetbureau](https://www.omines.nl/) 113 | in Eindhoven, the Netherlands. It is shared with the general public under the permissive MIT license, without 114 | any guarantee of fitness for any particular purpose. Refer to the included `LICENSE` file for more details. 115 | -------------------------------------------------------------------------------- /bin/install-infection: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd $(dirname $0)/.. 3 | 4 | wget https://github.com/infection/infection/releases/download/0.27.0/infection.phar 5 | wget https://github.com/infection/infection/releases/download/0.27.0/infection.phar.asc 6 | gpg --recv-keys C6D76C329EBADE2FB9C458CFC5095986493B4AA0 7 | gpg --with-fingerprint --verify infection.phar.asc infection.phar 8 | rm infection.phar.asc* 9 | chmod +x infection.phar 10 | -------------------------------------------------------------------------------- /bin/prepare-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | cd $(dirname $0)/.. 4 | 5 | PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix 6 | vendor/bin/phpstan 7 | XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text 8 | vendor/bin/infection --threads=max 9 | 10 | echo "All good, ready for commit!" 11 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "omines/oauth2-gitlab", 3 | "description": "GitLab OAuth 2.0 Client Provider for The PHP League OAuth2-Client", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Niels Keurentjes", 8 | "email": "niels.keurentjes@omines.com", 9 | "homepage": "https://www.omines.nl/" 10 | } 11 | ], 12 | "keywords": [ 13 | "oauth", 14 | "oauth2", 15 | "client", 16 | "authorization", 17 | "authorisation", 18 | "gitlab" 19 | ], 20 | "require": { 21 | "php": ">=8.1", 22 | "ext-mbstring": "*", 23 | "ext-intl": "*", 24 | "league/oauth2-client": "^2.4.1" 25 | }, 26 | "require-dev": { 27 | "friendsofphp/php-cs-fixer": "^3.65.0", 28 | "guzzlehttp/psr7": "^2.7.0", 29 | "http-interop/http-factory-guzzle": "^1.2", 30 | "infection/infection": "^0.27.11", 31 | "m4tthumphrey/php-gitlab-api": "^11.14", 32 | "mockery/mockery": "^1.6.12", 33 | "php-http/guzzle7-adapter": "^1.1.0", 34 | "phpstan/extension-installer": "^1.4.3", 35 | "phpstan/phpstan": "^2.0.4", 36 | "phpstan/phpstan-mockery": "^2.0.0", 37 | "phpstan/phpstan-phpunit": "^2.0.3", 38 | "phpunit/phpunit": "^10.5.39" 39 | }, 40 | "suggest": { 41 | "m4tthumphrey/php-gitlab-api": "For further API usage using the acquired OAuth2 token" 42 | }, 43 | "autoload": { 44 | "psr-4": { 45 | "Omines\\OAuth2\\Client\\": "src/" 46 | } 47 | }, 48 | "autoload-dev": { 49 | "psr-4": { 50 | "Omines\\OAuth2\\Client\\Test\\": "test/src/" 51 | } 52 | }, 53 | "extra": { 54 | "branch-alias": { 55 | "dev-master": "3.x-dev" 56 | } 57 | }, 58 | "config": { 59 | "sort-packages": true, 60 | "allow-plugins": { 61 | "phpstan/extension-installer": true, 62 | "php-http/discovery": true, 63 | "infection/extension-installer": true 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /infection.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/infection/infection/0.27.0/resources/schema.json", 3 | "timeout": 10, 4 | "source": { 5 | "directories": [ 6 | "src" 7 | ] 8 | }, 9 | "minMsi": 100, 10 | "minCoveredMsi": 100, 11 | "mutators": { 12 | "@default": true 13 | }, 14 | "logs": { 15 | "text": "log/infection.txt", 16 | "perMutator": "log/mutators.md", 17 | "stryker": { "report": "master" } 18 | } 19 | } -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | paths: 4 | - src 5 | - test 6 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | ./test/ 13 | 14 | 15 | 16 | 17 | ./src 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Provider/Exception/GitlabIdentityProviderException.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | final class GitlabIdentityProviderException extends IdentityProviderException 22 | { 23 | /** 24 | * Creates identity exception from response. 25 | * 26 | * @param ResponseInterface $response Response received from upstream 27 | * @param ?string $message Parsed message 28 | */ 29 | public static function fromResponse(ResponseInterface $response, ?string $message = null): IdentityProviderException 30 | { 31 | return new self($message ?? $response->getReasonPhrase() ?: self::class, $response->getStatusCode(), $response->getBody()->getContents()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Provider/Gitlab.php: -------------------------------------------------------------------------------- 1 | 25 | * 26 | * @phpstan-import-type ResourceOwner from GitlabResourceOwner 27 | */ 28 | class Gitlab extends AbstractProvider 29 | { 30 | use BearerAuthorizationTrait; 31 | 32 | public const DEFAULT_DOMAIN = 'https://gitlab.com'; 33 | public const DEFAULT_SCOPE = 'api'; 34 | public const SCOPE_SEPARATOR = ' '; 35 | 36 | private const PATH_API_USER = '/api/v4/user'; 37 | private const PATH_AUTHORIZE = '/oauth/authorize'; 38 | private const PATH_TOKEN = '/oauth/token'; 39 | 40 | public string $domain = self::DEFAULT_DOMAIN; 41 | 42 | /** 43 | * Get authorization url to begin OAuth flow. 44 | */ 45 | public function getBaseAuthorizationUrl(): string 46 | { 47 | return $this->domain . self::PATH_AUTHORIZE; 48 | } 49 | 50 | /** 51 | * Get access token url to retrieve token. 52 | * 53 | * @param mixed[] $params 54 | */ 55 | public function getBaseAccessTokenUrl(array $params): string 56 | { 57 | return $this->domain . self::PATH_TOKEN; 58 | } 59 | 60 | /** 61 | * Get provider url to fetch user details. 62 | */ 63 | public function getResourceOwnerDetailsUrl(AccessToken $token): string 64 | { 65 | return $this->domain . self::PATH_API_USER; 66 | } 67 | 68 | /** 69 | * Get the default scopes used by GitLab. 70 | * Current scopes are 'api', 'read_user', 'openid'. 71 | * 72 | * This returns an array with 'api' scope as default. 73 | * 74 | * @return string[] 75 | */ 76 | protected function getDefaultScopes(): array 77 | { 78 | return [self::DEFAULT_SCOPE]; 79 | } 80 | 81 | /** 82 | * GitLab uses a space to separate scopes. 83 | */ 84 | protected function getScopeSeparator(): string 85 | { 86 | return self::SCOPE_SEPARATOR; 87 | } 88 | 89 | /** 90 | * Check a provider response for errors. 91 | * 92 | * @param ResponseInterface $response Parsed response data 93 | * @param array{error?: string, message?: string}|scalar $data 94 | * @throws IdentityProviderException 95 | */ 96 | protected function checkResponse(ResponseInterface $response, mixed $data): void 97 | { 98 | if (!is_array($data)) { 99 | throw GitlabIdentityProviderException::fromResponse($response, 'Corrupted response'); 100 | } elseif ($response->getStatusCode() >= 400) { 101 | throw GitlabIdentityProviderException::fromResponse($response, $data['message'] ?? $response->getReasonPhrase()); 102 | } elseif (isset($data['error'])) { 103 | throw GitlabIdentityProviderException::fromResponse($response, $data['error']); 104 | } 105 | } 106 | 107 | /** 108 | * Generate a user object from a successful user details request. 109 | * 110 | * @param ResourceOwner $response 111 | */ 112 | protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface 113 | { 114 | $user = new GitlabResourceOwner($response, $token); 115 | 116 | return $user->setDomain($this->domain); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Provider/GitlabResourceOwner.php: -------------------------------------------------------------------------------- 1 | 22 | * 23 | * @phpstan-type ResourceOwner array{id: int, is_admin: bool, name: string, username: string, email: string, avatar_url: string, web_url: string, state: string, external: bool} 24 | */ 25 | class GitlabResourceOwner implements ResourceOwnerInterface 26 | { 27 | public const PATH_API = '/api/v4/'; 28 | 29 | /** @var ResourceOwner */ 30 | private array $data; 31 | 32 | private string $domain; 33 | private AccessToken $token; 34 | 35 | /** 36 | * Creates new resource owner. 37 | * 38 | * @param ResourceOwner $response 39 | */ 40 | public function __construct(array $response, AccessToken $token) 41 | { 42 | $this->data = $response; 43 | $this->token = $token; 44 | } 45 | 46 | /** 47 | * Returns the identifier of the authorized resource owner. 48 | */ 49 | public function getId(): int 50 | { 51 | return (int) ($this->data['id'] ?? 0); 52 | } 53 | 54 | /** 55 | * Returns an authenticated API client. 56 | * 57 | * Requires optional Gitlab API client to be installed. 58 | * 59 | * @infection-ignore-all Cannot be tested for infection due to external dependency 60 | */ 61 | public function getApiClient(?Builder $builder = null): Client 62 | { 63 | if (!class_exists('\\Gitlab\\Client')) { 64 | throw new \LogicException(__METHOD__ . ' requires package m4tthumphrey/php-gitlab-api to be installed and autoloaded'); // @codeCoverageIgnore 65 | } 66 | $client = new Client($builder); 67 | $client->setUrl(rtrim($this->domain, '/') . self::PATH_API); 68 | $client->authenticate($this->token->getToken(), Client::AUTH_OAUTH_TOKEN); 69 | 70 | return $client; 71 | } 72 | 73 | public function getDomain(): string 74 | { 75 | return $this->domain; 76 | } 77 | 78 | public function setDomain(string $domain): self 79 | { 80 | $this->domain = $domain; 81 | 82 | return $this; 83 | } 84 | 85 | /** 86 | * The full name of the owner. 87 | */ 88 | public function getName(): string 89 | { 90 | return $this->data['name']; 91 | } 92 | 93 | /** 94 | * Username of the owner. 95 | */ 96 | public function getUsername(): string 97 | { 98 | return $this->data['username']; 99 | } 100 | 101 | /** 102 | * Email address of the owner. 103 | */ 104 | public function getEmail(): string 105 | { 106 | return $this->data['email']; 107 | } 108 | 109 | /** 110 | * URL to the user's avatar. 111 | */ 112 | public function getAvatarUrl(): ?string 113 | { 114 | return $this->data['avatar_url']; 115 | } 116 | 117 | /** 118 | * URL to the user's profile page. 119 | */ 120 | public function getProfileUrl(): ?string 121 | { 122 | return $this->data['web_url']; 123 | } 124 | 125 | public function getToken(): AccessToken 126 | { 127 | return $this->token; 128 | } 129 | 130 | /** 131 | * Whether the user is active. 132 | */ 133 | public function isActive(): bool 134 | { 135 | return 'active' === ($this->data['state'] ?? null); 136 | } 137 | 138 | /** 139 | * Whether the user is an admin. 140 | */ 141 | public function isAdmin(): bool 142 | { 143 | return $this->data['is_admin'] ?? false; 144 | } 145 | 146 | /** 147 | * Whether the user is external. 148 | */ 149 | public function isExternal(): bool 150 | { 151 | return $this->data['external'] ?? true; 152 | } 153 | 154 | /** 155 | * Return all of the owner details available as an array. 156 | * 157 | * @return ResourceOwner 158 | */ 159 | public function toArray(): array 160 | { 161 | return $this->data; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /test/src/Provider/GitlabTest.php: -------------------------------------------------------------------------------- 1 | provider = new Gitlab([ 29 | 'clientId' => 'mock_client_id', 30 | 'clientSecret' => 'mock_secret', 31 | 'redirectUri' => 'none', 32 | ]); 33 | } 34 | 35 | public function tearDown(): void 36 | { 37 | m::close(); 38 | parent::tearDown(); 39 | } 40 | 41 | private function createSelfhostedProvider(string $domain): Gitlab 42 | { 43 | return new Gitlab([ 44 | 'domain' => $domain, 45 | 'clientId' => 'mock_client_id', 46 | 'clientSecret' => 'mock_secret', 47 | 'redirectUri' => 'none', 48 | ]); 49 | } 50 | 51 | public function testShorthandedSelfhostedConstructor(): void 52 | { 53 | $provider = $this->createSelfhostedProvider('https://gitlab.example.org'); 54 | $this->assertSame('https://gitlab.example.org/oauth/authorize', $provider->getBaseAuthorizationUrl()); 55 | } 56 | 57 | public function testAuthorizationUrl(): void 58 | { 59 | $url = $this->provider->getAuthorizationUrl(); 60 | $uri = parse_url($url); 61 | parse_str($uri['query'] ?? '', $query); 62 | 63 | $this->assertArrayHasKey('client_id', $query); 64 | $this->assertArrayHasKey('redirect_uri', $query); 65 | $this->assertArrayHasKey('state', $query); 66 | $this->assertArrayHasKey('scope', $query); 67 | $this->assertArrayHasKey('response_type', $query); 68 | $this->assertArrayHasKey('approval_prompt', $query); 69 | $this->assertNotEmpty($this->provider->getState()); 70 | } 71 | 72 | public function testScopes(): void 73 | { 74 | $options = ['scope' => [uniqid(), uniqid()]]; 75 | $url = $this->provider->getAuthorizationUrl($options); 76 | $this->assertStringContainsString(rawurlencode(implode(Gitlab::SCOPE_SEPARATOR, $options['scope'])), $url); 77 | 78 | // Default scope 79 | $this->assertStringContainsString('&scope=api&', $this->provider->getAuthorizationUrl()); 80 | } 81 | 82 | public function testGetAuthorizationUrl(): void 83 | { 84 | $url = $this->provider->getAuthorizationUrl(); 85 | $uri = parse_url($url); 86 | 87 | $this->assertEquals('/oauth/authorize', $uri['path'] ?? 'error on parsing'); 88 | } 89 | 90 | public function testGetBaseAccessTokenUrl(): void 91 | { 92 | $params = []; 93 | 94 | $url = $this->provider->getBaseAccessTokenUrl($params); 95 | $uri = parse_url($url); 96 | 97 | $this->assertEquals('/oauth/token', $uri['path'] ?? 'error on parsing'); 98 | } 99 | 100 | public function testGetAccessToken(): void 101 | { 102 | $response = m::mock('Psr\Http\Message\ResponseInterface'); 103 | $response->shouldReceive('getBody')->andReturn(Utils::streamFor('{"access_token":"mock_access_token", "scope":"repo,gist", "token_type":"bearer"}')); 104 | $response->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 105 | $response->shouldReceive('getStatusCode')->andReturn(200); 106 | 107 | $client = m::mock('GuzzleHttp\ClientInterface'); 108 | $client->shouldReceive('send')->times(1)->andReturn($response); 109 | $this->provider->setHttpClient($client); 110 | 111 | $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 112 | 113 | $this->assertInstanceOf(AccessToken::class, $token); 114 | $this->assertEquals('mock_access_token', $token->getToken()); 115 | $this->assertNull($token->getExpires()); 116 | $this->assertNull($token->getRefreshToken()); 117 | $this->assertNull($token->getResourceOwnerId()); 118 | } 119 | 120 | public function testSelfHostedGitlabDomainUrls(): void 121 | { 122 | $provider = $this->createSelfhostedProvider('https://gitlab.company.com'); 123 | 124 | $response = m::mock('Psr\Http\Message\ResponseInterface'); 125 | $response->shouldReceive('getBody')->times(1)->andReturn(Utils::streamFor('access_token=mock_access_token&expires=3600&refresh_token=mock_refresh_token&otherKey={1234}')); 126 | $response->shouldReceive('getHeader')->andReturn(['content-type' => 'application/x-www-form-urlencoded']); 127 | $response->shouldReceive('getStatusCode')->andReturn(200); 128 | 129 | $client = m::mock('GuzzleHttp\ClientInterface'); 130 | $client->shouldReceive('send')->times(1)->andReturn($response); 131 | $provider->setHttpClient($client); 132 | 133 | $token = $provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 134 | 135 | $this->assertInstanceOf(AccessToken::class, $token); 136 | $this->assertEquals($provider->domain . '/oauth/authorize', $provider->getBaseAuthorizationUrl()); 137 | $this->assertEquals($provider->domain . '/oauth/token', $provider->getBaseAccessTokenUrl([])); 138 | $this->assertEquals($provider->domain . '/api/v4/user', $provider->getResourceOwnerDetailsUrl($token)); 139 | // $this->assertEquals($provider->domain.'/api/v4/user/emails', $provider->urlUserEmails($token)); 140 | } 141 | 142 | public function testUserData(): GitlabResourceOwner 143 | { 144 | $userdata = [ 145 | 'id' => rand(1000, 9999), 146 | 'name' => uniqid('name'), 147 | 'username' => uniqid('username'), 148 | 'email' => uniqid('email'), 149 | 'avatar_url' => 'https://example.org/' . uniqid('avatar'), 150 | 'web_url' => 'https://example.org/' . uniqid('web'), 151 | 'state' => 'active', 152 | 'is_admin' => true, 153 | 'external' => false, 154 | ]; 155 | 156 | $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); 157 | $postResponse->shouldReceive('getBody')->andReturn(Utils::streamFor('access_token=mock_access_token&expires=3600&refresh_token=mock_refresh_token&otherKey={1234}')); 158 | $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'application/x-www-form-urlencoded']); 159 | $postResponse->shouldReceive('getStatusCode')->andReturn(200); 160 | 161 | $userResponse = m::mock('Psr\Http\Message\ResponseInterface'); 162 | $userResponse->shouldReceive('getBody')->andReturn(Utils::streamFor(json_encode($userdata))); 163 | $userResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); 164 | $userResponse->shouldReceive('getStatusCode')->andReturn(200); 165 | 166 | $client = m::mock('GuzzleHttp\ClientInterface'); 167 | $client->shouldReceive('send') 168 | ->times(2) 169 | ->andReturn($postResponse, $userResponse); 170 | $this->provider->setHttpClient($client); 171 | 172 | $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 173 | $this->assertInstanceOf(AccessToken::class, $token); 174 | $user = $this->provider->getResourceOwner($token); 175 | $this->assertInstanceOf(GitlabResourceOwner::class, $user); 176 | 177 | $this->assertEquals($userdata, $user->toArray()); 178 | $this->assertSame($userdata['id'], $user->getId()); 179 | $this->assertSame($userdata['name'], $user->getName()); 180 | $this->assertSame($userdata['username'], $user->getUsername()); 181 | $this->assertSame($userdata['email'], $user->getEmail()); 182 | $this->assertSame($userdata['avatar_url'], $user->getAvatarUrl()); 183 | $this->assertSame($userdata['web_url'], $user->getProfileUrl()); 184 | $this->assertSame('https://gitlab.com', $user->getDomain()); 185 | $this->assertSame('mock_access_token', $user->getToken()->getToken()); 186 | $this->assertTrue($user->isActive()); 187 | $this->assertTrue($user->isAdmin()); 188 | $this->assertFalse($user->isExternal()); 189 | 190 | return $user; 191 | } 192 | 193 | public function testBuggyResourceOwner(): void 194 | { 195 | /** @phpstan-ignore-next-line Violating type requirements on purpose */ 196 | $owner = new GitlabResourceOwner([ 197 | 'id' => 'foo', // Should be an integer 198 | 'is_admin' => 'bar', // Should be a bool 199 | ], new AccessToken([ 200 | 'access_token' => 'foobar', 201 | ])); 202 | 203 | $this->assertSame(0, $owner->getId()); 204 | $this->assertTrue($owner->isAdmin()); 205 | } 206 | 207 | public function testDefaultValuesForResourceOwner(): void 208 | { 209 | /** @phpstan-ignore-next-line Violating type requirements on purpose */ 210 | $owner = new GitlabResourceOwner([ 211 | ], new AccessToken([ 212 | 'access_token' => 'foobar', 213 | ])); 214 | 215 | $this->assertSame(0, $owner->getId()); 216 | $this->assertFalse($owner->isAdmin()); 217 | $this->assertFalse($owner->isActive()); 218 | $this->assertTrue($owner->isExternal()); 219 | } 220 | 221 | /** 222 | * @depends testUserData 223 | */ 224 | public function testApiClient(GitlabResourceOwner $owner): void 225 | { 226 | $client = $owner->getApiClient(); 227 | 228 | $this->assertSame(\Gitlab\Client::class, get_class($client)); 229 | } 230 | 231 | /** 232 | * @return int[][] 233 | */ 234 | public static function provideErrorCodes(): array 235 | { 236 | return [ 237 | [400], 238 | [404], 239 | [500], 240 | [rand(401, 600)], 241 | ]; 242 | } 243 | 244 | /** 245 | * @dataProvider provideErrorCodes 246 | */ 247 | public function testExceptionThrownWhenErrorObjectReceived(int $status): void 248 | { 249 | $response = new Response($status, ['content-type' => 'json'], '{"message": "Validation Failed","errors": [{"resource": "Issue","field": "title","code": "missing_field"}]}'); 250 | 251 | $client = m::mock('GuzzleHttp\ClientInterface'); 252 | $client->shouldReceive('send') 253 | ->times(1) 254 | ->andReturn($response); 255 | $this->provider->setHttpClient($client); 256 | 257 | $this->expectException(IdentityProviderException::class); 258 | $this->expectExceptionMessage('Validation Failed'); 259 | $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 260 | } 261 | 262 | public function testExceptionThrownWhenOAuthErrorReceived(): void 263 | { 264 | $response = new Response(200, ['content-type' => 'json'], '{"error": "bad_verification_code","error_description": "The code passed is incorrect or expired.","error_uri": "https://developer.github.com/v4/oauth/#bad-verification-code"}'); 265 | 266 | $client = m::mock('GuzzleHttp\ClientInterface'); 267 | $client->shouldReceive('send') 268 | ->times(1) 269 | ->andReturn($response); 270 | $this->provider->setHttpClient($client); 271 | 272 | $this->expectException(IdentityProviderException::class); 273 | $this->expectExceptionMessage('bad_verification_code'); 274 | $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 275 | } 276 | 277 | public function testExceptionThrownWhenUnknownErrorReceived(): void 278 | { 279 | $response = new Response(200, ['content-type' => 'json'], '684'); 280 | 281 | $client = m::mock('GuzzleHttp\ClientInterface'); 282 | $client->shouldReceive('send') 283 | ->times(1) 284 | ->andReturn($response); 285 | $this->provider->setHttpClient($client); 286 | 287 | $this->expectException(IdentityProviderException::class); 288 | $this->expectExceptionMessage('Corrupted response'); 289 | $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); 290 | } 291 | } 292 | --------------------------------------------------------------------------------