Repository: legalthings/sso Branch: master Commit: e806f50c387d Files: 54 Total size: 107.8 KB Directory structure: gitextract_z0pcsndb/ ├── .gitattributes ├── .github/ │ └── workflows/ │ └── php.yml ├── .gitignore ├── .scrutinizer.yml ├── LICENSE ├── README.md ├── codeception.yml ├── composer.json ├── demo/ │ ├── ajax-broker/ │ │ ├── api.php │ │ ├── app.js │ │ ├── attach.php │ │ ├── index.html │ │ └── verify.php │ ├── broker/ │ │ ├── error.php │ │ ├── include/ │ │ │ ├── attach.php │ │ │ └── functions.php │ │ ├── index.php │ │ ├── login.php │ │ └── logout.php │ └── server/ │ ├── api/ │ │ ├── info.php │ │ ├── login.php │ │ └── logout.php │ ├── attach.php │ └── include/ │ ├── config.php │ └── start_broker_session.php ├── phpcs.xml ├── phpstan.neon ├── src/ │ ├── Broker/ │ │ ├── Broker.php │ │ ├── Cookies.php │ │ ├── Curl.php │ │ ├── NotAttachedException.php │ │ ├── RequestException.php │ │ └── Session.php │ └── Server/ │ ├── BrokerException.php │ ├── ExceptionInterface.php │ ├── GlobalSession.php │ ├── Server.php │ ├── ServerException.php │ └── SessionInterface.php └── tests/ ├── _bootstrap.php ├── _output/ │ └── .gitignore ├── _support/ │ ├── DemoTester.php │ ├── Helper/ │ │ ├── Demo.php │ │ └── Unit.php │ ├── PhpBuiltInServer.php │ └── UnitTester.php ├── demo/ │ └── DemoCept.php ├── demo.suite.yml ├── unit/ │ ├── Broker/ │ │ ├── AttachTest.php │ │ └── RequestTest.php │ ├── Server/ │ │ ├── AttachTest.php │ │ └── BrokerSessionTest.php │ └── TokenTrait.php └── unit.suite.yml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ /demo export-ignore /tests export-ignore /.gitattributes export-ignore /.gitignore export-ignore /.scrutinizer.yml export-ignore /.travis.yml export-ignore /phpunit.xml.dist export-ignore /phpcs.xml.dist export-ignore /phpstan.neon export-ignore /README.md export-ignore ================================================ FILE: .github/workflows/php.yml ================================================ name: PHP on: push: branches: [ master ] pull_request: branches: [ master ] jobs: run: runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - php: 8.0 - php: 8.1 - php: 8.2 coverage: '--coverage --coverage-xml' name: PHP ${{ matrix.php }} steps: - uses: actions/checkout@v2 with: fetch-depth: 10 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} coverage: xdebug - name: Validate composer.json run: composer validate - name: Install dependencies run: composer update --prefer-dist --no-progress --no-suggest - name: Run Codeception run: vendor/bin/codecept run ${{ matrix.coverage }} - name: Upload coverage to Scrutinizer if: ${{ matrix.coverage }} uses: sudo-bot/action-scrutinizer@latest with: cli-args: "--format=php-clover build/logs/clover.xml --revision=${{ github.event.pull_request.head.sha || github.sha }}" ================================================ FILE: .gitignore ================================================ .DS_Store nbproject /vendor composer.lock tests/_output/* # Elastic Beanstalk Files .elasticbeanstalk/* !.elasticbeanstalk/*.cfg.yml !.elasticbeanstalk/*.global.yml /tests/_support/_generated/ .idea ================================================ FILE: .scrutinizer.yml ================================================ #language: php checks: php: true filter: excluded_paths: - tests build: nodes: analysis: environment: php: 8.2 postgresql: false redis: false mongodb: false tests: override: - phpcs-run src - command: vendor/bin/phpstan analyze --error-format=checkstyle | sed '/^\s*$/d' > phpstan-checkstyle.xml analysis: file: phpstan-checkstyle.xml format: 'general-checkstyle' - php-scrutinizer-run ================================================ FILE: LICENSE ================================================ Copyright (c) 2020 Arnold Daniels 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. ================================================ FILE: README.md ================================================ ![jasny-banner](https://user-images.githubusercontent.com/100821/62123924-4c501c80-b2c9-11e9-9677-2ebc21d9b713.png) Single Sign-On for PHP ======== [![PHP](https://github.com/jasny/sso/workflows/PHP/badge.svg)](https://github.com/jasny/sso/actions) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/jasny/sso/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/jasny/sso/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/jasny/sso/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/jasny/sso/?branch=master) [![Packagist Stable Version](https://img.shields.io/packagist/v/jasny/sso.svg)](https://packagist.org/packages/jasny/sso) [![Packagist License](https://img.shields.io/packagist/l/jasny/sso.svg)](https://packagist.org/packages/jasny/sso) Jasny SSO is a relatively simple and straightforward solution for single sign on (SSO). With SSO, logging into a single website will authenticate you for all affiliate sites. The sites don't need to share a toplevel domain. ### How it works When using SSO, we can distinguish 3 parties: * Client - This is the browser of the visitor * Broker - The website which is visited * Server - The place that holds the user info and credentials The broker has an id and a secret. These are known to both the broker and server. When the client visits the broker, it creates a random token, which is stored in a cookie. The broker will then send the client to the server, passing along the broker's id and token. The server creates a hash using the broker id, broker secret and the token. This hash is used to create a link to the user's session. When the link is created the server redirects the client back to the broker. The broker can create the same link hash using the token (from the cookie), the broker id and the broker secret. When doing requests, it passes that hash as a session id. The server will notice that the session id is a link and use the linked session. As such, the broker and client are using the same session. When another broker joins in, it will also use the same session. For a more in depth explanation, please [read this article](https://github.com/jasny/sso/wiki). ### How is this different from OAuth? With OAuth, you can authenticate a user at an external server and get access to their profile info. However, you aren't sharing a session. A user logs in to website foo.com using Google OAuth. Next they visit website bar.org which also uses Google OAuth. Regardless of that, they are still required to press the 'login' button on bar.org. With Jasny SSO both websites use the same session. So when the user visits bar.org, they are automatically logged in. When they log out (on either of the sites), they are logged out for both. ## Installation Install this library through composer composer require jasny/sso ## Demo There is a demo server and two demo brokers as example. One with normal redirects and one using [JSONP](https://en.wikipedia.org/wiki/JSONP) / AJAX. To prove it's working you should setup the server and two or more brokers, each on their own machine and their own (sub)domain. However, you can also run both server and brokers on your own machine, simply to test it out. On *nix (Linux / Unix / OSX) run: php -S localhost:8000 -t demo/server/ export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Alice SSO_BROKER_SECRET=8iwzik1bwd; php -S localhost:8001 -t demo/broker/ export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Greg SSO_BROKER_SECRET=7pypoox2pc; php -S localhost:8002 -t demo/broker/ export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Julius SSO_BROKER_SECRET=ceda63kmhp; php -S localhost:8003 -t demo/ajax-broker/ Now open some tabs and visit * http://localhost:8001 * http://localhost:8002 * http://localhost:8003 username | password -------- | -------- jackie | jackie123 john | john123 _Note that after logging in, you need to refresh on the other brokers to see the effect._ # Usage ## Server The `Server` class takes a callback as first constructor argument. This callback should look up the secret for a broker based on the id. The second argument must be a PSR-16 compatible cache object. It's used to store the link between broker token and client session. ```php use Jasny\SSO\Server\Server; $brokers = [ 'foo' => ['secret' => '8OyRi6Ix1x', 'domains' => ['example.com']], // ... ]; $server = new Server( fn($id) => $brokers[$id] ?? null, // Unique secret and allowed domains for each broker. new Cache() // Any PSR-16 compatible cache ); ``` _In this example the brokers are simply configured as an array, but typically you want to fetch the broker info from a DB._ ### Attach A client needs to attach the broker token to the session id by doing an HTTP request to the server. This request can be handled by calling `attach()`. The `attach()` method returns a verification code. This code must be returned to the broker, as it's needed to calculate the checksum. ```php $verificationCode = $server->attach(); ``` If it's not possible to attach (for instance in case of an incorrect checksum), an Exception is thrown. ### Handle broker API request After the client session is attached to the broker token, the broker is able to send API requests on behalf of the client. Calling the `startBrokerSession()` method with start the session of the client based on the bearer token. This means that these request the server can access the session information of the client through `$_SESSION`. ``` $server->startBrokerSession(); ``` The broker could use this to login, logout, get user information, etc. The API for handling such requests is outside the scope of the project. However since the broker uses normal sessions, any existing the authentication can be used. _If you're lookup for an authentication library, consider using [Jasny Auth](https://github.com/jasny/auth)._ ### PSR-7 By default, the library works with superglobals like `$_GET` and `$_SERVER`. Alternatively it can use a PSR-7 server request. This can be passed to `attach()` and `startBrokerSession()` as argument. ```php $verificationCode = $server->attach($serverRequest); ``` ### Session interface By default, the library uses the superglobal `$_SESSION` and the `php_session_*()` functions. It does this through the `GlobalSession` object, which implements `SessionInterface`. For projects that use alternative sessions, it's possible to create a wrapper that implements `SessionInterface`. ```php use Jasny\SSO\Server\SessionInterface; class CustomerSessionHandler implements SessionInterface { // ... } ``` The `withSession()` methods creates a copy of the Server object with the custom session interface. ```php $server = (new Server($callback, $cache)) ->withSession(new CustomerSessionHandler()); ``` The `withSession()` method can also be used with a mock object for testing. ### Logging Enable logging for debugging and catching issues. ```php $server = (new Server($callback, $cache)) ->withLogging(new Logger()); ``` Any PSR-3 compatible logger can be used, like [Monolog](https://packagist.org/packages/monolog/monolog) or [Loggy](https://packagist.org/packages/yubb/loggy). The `context` may contain the broker id, token, and session id. ## Broker When creating a `Broker` instance, you need to pass the server url, broker id and broker secret. The broker id and secret needs to match the secret registered at the server. **CAVEAT**: *The broker id MUST be alphanumeric.* ### Attach Before the broker can do API requests on the client's behalf, the client needs to attach the broker token to the client session. For this, the client must do an HTTP request to the SSO Server. The `getAttachUrl()` method will generate a broker token for the client and use it to create an attach URL. The method takes an array of query parameters as single argument. There are several methods in making the client do an HTTP request. The broker can redirect the client or do a request via the browser using AJAX or loading an image. ```php use Jasny\SSO\Broker\Broker; // Configure the broker. $broker = new Broker( getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET') ); // Attach through redirect if the client isn't attached yet. if (!$broker->isAttached()) { $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]); header("Location: $attachUrl", true, 303); echo "You're redirected to $attachUrl"; exit(); } ``` ### Verify Upon verification the SSO Server will return a verification code (as a query parameter or in the JSON response). The code is used to calculate the checksum. The verification code prevents session hijacking using an attach link. ```php if (isset($_GET['sso_verify'])) { $broker->verify($_GET['sso_verify']); } ``` ### API requests Once attached, the broker is able to do API requests on behalf of the client. This can be done by - using the broker `request()` method, or by - using any HTTP client like Guzzle #### Broker request ```php // Post to modify the user info $broker->request('POST', '/login', $credentials); // Get user info $user = $broker->request('GET', '/user'); ``` The `request()` method uses Curl to send HTTP requests, adding the bearer token for authentication. It expects a JSON response and will automatically decode it. #### HTTP library (Guzzle) To use a library like [Guzzle](http://docs.guzzlephp.org/) or [Httplug](http://httplug.io/), get the bearer token using `getBearerToken()` and set the `Authorization` header ```php $guzzle = new GuzzleHttp\Client(['base_uri' => 'https://sso-server.example.com']); $res = $guzzle->request('GET', '/user', [ 'headers' => [ 'Authorization' => 'Bearer ' . $broker->getBearerToken() ] ]); ``` ### Client state By default, the Broker uses the cookies (`$_COOKIE` and `setcookie()`) via the `Cookies` class to persist the client's SSO token. #### Cookie Instantiate a new `Cookies` object with custom parameters to modify things like cookie TTL, domain and https only. ```php use Jasny\SSO\Broker\{Broker,Cookies}; $broker = (new Broker(getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET'))) ->withTokenIn(new Cookies(7200, '/myapp', 'example.com', true)); ``` _(The cookie can never be accessed by the browser.)_ #### Session Alternatively, you can store the SSO token in a PHP session for the broker by using `Session`. ```php use Jasny\SSO\Broker\{Broker,Session}; session_start(); $broker = (new Broker(getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET'))) ->withTokenIn(new Session()); ``` #### Custom The method accepts any object that implements `ArrayAccess`, allowing you to create a custom handler if needed. ```php class CustomStateHandler implements \ArrayAccess { // ... } ``` This can also be used with a mock object for testing. ================================================ FILE: codeception.yml ================================================ actor: Tester paths: tests: tests log: tests/_output data: tests/_data support: tests/_support envs: tests/_envs bootstrap: _bootstrap.php settings: colors: true memory_limit: 1024M extensions: enabled: - Codeception\Extension\RunFailed ================================================ FILE: composer.json ================================================ { "name": "jasny/sso", "description": "Simple Single Sign-On", "keywords": ["sso", "auth"], "license": "MIT", "homepage": "https://github.com/jasny/sso/wiki", "authors": [ { "name": "Arnold Daniels", "email": "arnold@jasny.net", "homepage": "http://www.jasny.net" } ], "support": { "issues": "https://github.com/jasny/sso/issues", "source": "https://github.com/jasny/sso" }, "require": { "php": "^8.0", "ext-json": "*", "jasny/immutable": "^2.1", "psr/simple-cache": "*", "psr/log": "*" }, "require-dev": { "phpstan/phpstan": "^0.12.59", "codeception/codeception": "^4.1", "codeception/module-phpbrowser": "^1.0", "codeception/module-rest": "^1.2", "desarrolla2/cache": "^3.0", "jasny/http-message": "^1.3", "jasny/php-code-quality": "^2.6.0", "jasny/phpunit-extension": "^0.3.2", "yubb/loggy": "^2.1" }, "autoload": { "psr-4": { "Jasny\\SSO\\": "src/" } }, "autoload-dev": { "psr-4": { "Jasny\\Tests\\SSO\\": "tests/unit/" } }, "scripts": { "test": [ "phpstan analyse", "codecept run", "phpcs -p src" ] }, "config": { "preferred-install": "dist", "sort-packages": true, "optimize-autoloader": true }, "minimum-stability": "dev", "prefer-stable": true } ================================================ FILE: demo/ajax-broker/api.php ================================================ request($_SERVER['REQUEST_METHOD'], $path, $_POST); } catch (Exception $e) { $status = $e->getCode() ?: 500; $result = ['error' => $e->getMessage()]; } // REST if (!$result) { http_response_code(204); } else { http_response_code(isset($status) ? $status : 200); header("Content-Type: application/json"); echo json_encode($result); } ================================================ FILE: demo/ajax-broker/app.js ================================================ +function ($) { // Init attach(); /** * Attach session. * Will redirect to SSO server. */ function attach() { const req = $.ajax({ url: 'attach.php', crossDomain: true, dataType: 'jsonp' }); req.done(function (data, code) { if (code && code >= 400) { // jsonp failure showError(data); return; } $.ajax({method: 'POST', url: 'verify.php', data: data}).done(function () { doApiRequest('info', null, showUserInfo); }); }); req.fail(function (jqxhr) { showError(jqxhr.responseJSON || jqxhr.textResponse) }); } /** * Do an AJAX request to the API * * @param command API command * @param params POST data * @param callback Callback function */ function doApiRequest(command, params, callback) { const req = $.ajax({ url: 'api.php?command=' + command, method: params ? 'POST' : 'GET', data: params, dataType: 'json' }); req.done(callback); req.fail(function (jqxhr) { showError(jqxhr.responseJSON || jqxhr.textResponse); }); } /** * Display the error message * * @param data */ function showError(data) { const message = typeof data === 'object' && data.error ? data.error : 'Unexpected error'; $.growl.error({message: message}); } /** * Display user info * * @param info */ function showUserInfo(info) { const body = $('body'); const userInfo = $('#user-info'); body.removeClass('anonymous authenticated'); userInfo.html(''); if (info) { for (const key in info) { userInfo.append($('
').text(key)); userInfo.append($('
').text(info[key])); } } body.addClass(info ? 'authenticated' : 'anonymous'); } /** * Submit login form through AJAX */ $('#login-form').on('submit', function (e) { e.preventDefault(); $('#error').text('').hide(); var data = { username: this.username.value, password: this.password.value }; doApiRequest('login', data, showUserInfo); }); $('#logout').on('click', function () { doApiRequest('logout', {}, () => showUserInfo(null)); }); }(jQuery); ================================================ FILE: demo/ajax-broker/attach.php ================================================ isAttached()) { echo $jsCallback . '(null, 200)'; } // Attach through redirect if the client isn't attached yet. $url = $broker->getAttachUrl(['callback' => $jsCallback]); header("Location: $url", true, 303); ================================================ FILE: demo/ajax-broker/index.html ================================================ Single Sign-On Ajax demo

Single Sign-On Ajax demo

Logged in

================================================ FILE: demo/ajax-broker/verify.php ================================================ verify($_POST['verify']); http_response_code(204); ================================================ FILE: demo/broker/error.php ================================================ getMessage() : ($_GET['sso_error'] ?? "Unknown error"); $errorDetails = isset($exception) && $exception->getPrevious() !== null ? $exception->getPrevious()->getMessage() : null; ?> Single Sign-On demo (<?= $brokerId ?>)

Single Sign-On demo ()

Try again
================================================ FILE: demo/broker/include/attach.php ================================================ verify($_GET['sso_verify']); $url = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; $redirectUrl = preg_replace('/sso_verify=\w+&|[?&]sso_verify=\w+$/', '', $url); redirect($redirectUrl); exit(); } // Attach through redirect if the client isn't attached yet. if (!$broker->isAttached()) { $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]); redirect($attachUrl); exit(); } return $broker; ================================================ FILE: demo/broker/include/functions.php ================================================ $url"; } ================================================ FILE: demo/broker/index.php ================================================ request('GET', '/api/info.php'); } catch (\RuntimeException $exception) { require __DIR__ . '/error.php'; exit(); } ?> <?= $broker->getBrokerId() ?> — Single Sign-On demo

Single Sign-On demo (Broker: getBrokerId() ?>)

Logged out

Login

Logged in

Logout
================================================ FILE: demo/broker/login.php ================================================ $_POST['username'], 'password' => $_POST['password'] ]; $broker->request('POST', '/api/login.php', $credentials); redirect('index.php'); exit(); } catch (\RuntimeException $exception) { $error = $exception->getMessage(); } } // Show the form in case of GET request ?> <?= $broker->getBrokerId() ?> | Login (Single Sign-On demo)

Single Sign-On demo (Broker: getBrokerId() ?>)

================================================ FILE: demo/broker/logout.php ================================================ request('POST', 'api/logout.php'); } catch (\RuntimeException $exception) { require __DIR__ . '/error.php'; exit(); } redirect('index.php'); ================================================ FILE: demo/server/api/info.php ================================================ $username] + $config['users'][$username]; unset($info['password']); header('Content-Type: application/json'); echo json_encode($info); ================================================ FILE: demo/server/api/login.php ================================================ "Invalid credentials"]); exit(); } // Store the current user in the session. $_SESSION['user'] = $username; // Output user info as JSON. $info = ['username' => $username] + $config['users'][$username]; unset($info['password']); header('Content-Type: application/json'); echo json_encode($info); ================================================ FILE: demo/server/api/logout.php ================================================ withLogger(new Loggy('SSO')); try { // Attach the broker token to the user session. Uses query parameters from $_GET. $verificationCode = $ssoServer->attach(); $error = null; } catch (SSOException $exception) { $verificationCode = null; $error = ['code' => $exception->getCode(), 'message' => $exception->getMessage()]; } // The token is attached; output 'success'. // In this demo we support multiple types of attaching the session. If you choose to support only one method, // you don't need to detect the return type. $returnType = (isset($_GET['return_url']) ? 'redirect' : null) ?? (isset($_GET['callback']) ? 'jsonp' : null) ?? (strpos($_SERVER['HTTP_ACCEPT'], 'text/html') !== false ? 'html' : null) ?? (strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false ? 'json' : null); switch ($returnType) { case 'json': header('Access-Control-Allow-Origin: *'); header('Content-type: application/json'); http_response_code($error['code'] ?? 200); echo json_encode($error ?? ['verify' => $verificationCode]); break; case 'jsonp': $callback = $_GET['callback']; if (!preg_match('/^[a-z_]\w*$/i', $callback)) { http_response_code(400); header('Content-Type: text/plain'); echo "JSONP callback must be a valid js function name"; break; } header('Content-type: application/javascript'); $data = json_encode($error ?? ['verify' => $verificationCode]); $responseCode = $error['code'] ?? 200; echo "{$callback}($data, $responseCode);"; break; case 'redirect': $query = isset($error) ? 'sso_error=' . $error['message'] : 'sso_verify=' . $verificationCode; $url = $_GET['return_url'] . (strpos($_GET['return_url'], '?') === false ? '?' : '&') . $query; header('Location: ' . $url, true, 303); echo "You're being redirected to $url"; break; default: http_response_code(400); header('Content-Type: text/plain'); echo "Missing 'return_url' query parameter"; break; } ================================================ FILE: demo/server/include/config.php ================================================ [ 'Alice' => [ 'secret' => '8iwzik1bwd', 'domains' => ['localhost'], ], 'Greg' => [ 'secret' => '7pypoox2pc', 'domains' => ['localhost'], ], 'Julius' => [ 'secret' => 'ceda63kmhp', 'domains' => ['localhost'], ], ], 'users' => [ 'jackie' => [ 'fullname' => 'Jackie Black', 'email' => 'jackie.black@example.com', 'password' => '$2y$10$lVUeiphXLAm4pz6l7lF9i.6IelAqRxV4gCBu8GBGhCpaRb6o0qzUO' // jackie123 ], 'john' => [ 'fullname' => 'John Doe', 'email' => 'john.doe@example.com', 'password' => '$2y$10$RU85KDMhbh8pDhpvzL6C5.kD3qWpzXARZBzJ5oJ2mFoW7Ren.apC2' // john123 ], ], ]; ================================================ FILE: demo/server/include/start_broker_session.php ================================================ withLogger(new Loggy('SSO')); // Start the session using the broker bearer token (rather than a session cookie). try { $ssoServer->startBrokerSession(); } catch (SsoException $exception) { $code = $exception->getCode(); $message = $code === 403 ? "Invalid or expired bearer token" : $exception->getMessage(); http_response_code($code); if ($code === 401) { header('WWW-Authenticate: Bearer'); } header('Content-Type: application/json'); echo json_encode(['error' => $message]); exit(); } return $ssoServer; ================================================ FILE: phpcs.xml ================================================ The Jasny coding standard. ================================================ FILE: phpstan.neon ================================================ parameters: level: 7 paths: - src reportUnmatchedIgnoredErrors: false includes: - vendor/phpstan/phpstan-strict-rules/rules.neon ================================================ FILE: src/Broker/Broker.php ================================================ */ protected $state; /** * @var Curl */ protected $curl; /** * Class constructor * * @param string $url Url of SSO server * @param string $broker My identifier, given by SSO provider. * @param string $secret My secret word, given by SSO provider. */ public function __construct(string $url, string $broker, string $secret) { if (!(bool)preg_match('~^https?://~', $url)) { throw new \InvalidArgumentException("Invalid SSO server URL '$url'"); } if ((bool)preg_match('/\W/', $broker)) { throw new \InvalidArgumentException("Invalid broker id '$broker': must be alphanumeric"); } $this->url = $url; $this->broker = $broker; $this->secret = $secret; $this->state = new Cookies(); } /** * Get a copy with a different handler for the user state (like cookie or session). * * @param \ArrayAccess $handler * @return static */ public function withTokenIn(\ArrayAccess $handler): self { return $this->withProperty('state', $handler); } /** * Set a custom wrapper for cURL. * * @param Curl $curl * @return static */ public function withCurl(Curl $curl): self { return $this->withProperty('curl', $curl); } /** * Get Wrapped cURL. */ protected function getCurl(): Curl { if (!isset($this->curl)) { $this->curl = new Curl(); // @codeCoverageIgnore } return $this->curl; } /** * Get the broker identifier. */ public function getBrokerId(): string { return $this->broker; } /** * Get information from cookie. */ protected function initialize(): void { if ($this->initialized) { return; } $this->token = $this->state[$this->getCookieName('token')] ?? null; $this->verificationCode = $this->state[$this->getCookieName('verify')] ?? null; $this->initialized = true; } /** * @return string|null */ protected function getToken(): ?string { $this->initialize(); return $this->token; } /** * @return string|null */ protected function getVerificationCode(): ?string { $this->initialize(); return $this->verificationCode; } /** * Get the cookie name. * The broker name is part of the cookie name. This resolves issues when multiple brokers are on the same domain. */ protected function getCookieName(string $type): string { $brokerName = preg_replace('/[_\W]+/', '_', strtolower($this->broker)); return "sso_{$type}_{$brokerName}"; } /** * Generate session id from session key * * @throws NotAttachedException */ public function getBearerToken(): string { $token = $this->getToken(); $verificationCode = $this->getVerificationCode(); if ($verificationCode === null) { throw new NotAttachedException("The client isn't attached to the SSO server for this broker. " . "Make sure that the '" . $this->getCookieName('verify') . "' cookie is set."); } return "SSO-{$this->broker}-{$token}-" . $this->generateChecksum("bearer:$verificationCode"); } /** * Generate session token. */ protected function generateToken(): void { $this->token = base_convert(bin2hex(random_bytes(32)), 16, 36); $this->state[$this->getCookieName('token')] = $this->token; } /** * Clears session token. */ public function clearToken(): void { unset($this->state[$this->getCookieName('token')]); unset($this->state[$this->getCookieName('verify')]); $this->token = null; $this->verificationCode = null; } /** * Check if we have an SSO token. */ public function isAttached(): bool { return $this->getVerificationCode() !== null; } /** * Get URL to attach session at SSO server. * * @param array $params * @return string */ public function getAttachUrl(array $params = []): string { if ($this->getToken() === null) { $this->generateToken(); } $data = [ 'broker' => $this->broker, 'token' => $this->getToken(), 'checksum' => $this->generateChecksum('attach') ]; return $this->url . "?" . http_build_query($data + $params); } /** * Verify attaching to the SSO server by providing the verification code. */ public function verify(string $code): void { $this->initialize(); if ($this->verificationCode === $code) { return; } if ($this->verificationCode !== null) { trigger_error("SSO attach already verified", E_USER_WARNING); return; } $this->verificationCode = $code; $this->state[$this->getCookieName('verify')] = $code; } /** * Generate checksum for a broker. */ protected function generateChecksum(string $command): string { return base_convert(hash_hmac('sha256', $command . ':' . $this->token, $this->secret), 16, 36); } /** * Get the request url for a command * * @param string $path * @param array|string $params Query parameters * @return string */ protected function getRequestUrl(string $path, $params = ''): string { $query = is_array($params) ? http_build_query($params) : $params; $base = $path[0] === '/' ? preg_replace('~^(\w+://[^/]+).*~', '$1', $this->url) : preg_replace('~/[^/]*$~', '', $this->url); return $base . '/' . ltrim($path, '/') . ($query !== '' ? '?' . $query : ''); } /** * Send an HTTP request to the SSO server. * * @param string $method HTTP method: 'GET', 'POST', 'DELETE' * @param string $path Relative path * @param array|string $data Query or post parameters * @return mixed * @throws RequestException */ public function request(string $method, string $path, $data = '') { $url = $this->getRequestUrl($path, $method === 'POST' ? '' : $data); $headers = [ 'Accept: application/json', 'Authorization: Bearer ' . $this->getBearerToken() ]; ['httpCode' => $httpCode, 'contentType' => $contentType, 'body' => $body] = $this->getCurl()->request($method, $url, $headers, $method === 'POST' ? $data : ''); return $this->handleResponse($httpCode, $contentType, $body); } /** * Handle the response of the cURL request. * * @param int $httpCode HTTP status code * @param string|null $ctHeader Content-Type header * @param string $body Response body * @return mixed * @throws RequestException */ protected function handleResponse(int $httpCode, $ctHeader, string $body) { if ($httpCode === 204) { return null; } [$contentType] = explode(';', $ctHeader, 2); if ($contentType != 'application/json') { throw new RequestException( "Expected 'application/json' response, got '$contentType'", 500, new RequestException($body, $httpCode) ); } try { $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $exception) { throw new RequestException("Invalid JSON response from server", 500, $exception); } if ($httpCode >= 400) { throw new RequestException($data['error'] ?? $body, $httpCode); } return $data; } } ================================================ FILE: src/Broker/Cookies.php ================================================ * @codeCoverageIgnore */ class Cookies implements \ArrayAccess { /** @var int */ protected int $ttl; /** @var string */ protected string $path; /** @var string */ protected string $domain; /** @var bool */ protected bool $secure; public function __construct(int $ttl = 3600, string $path = '', string $domain = '', bool $secure = false) { $this->ttl = $ttl; $this->path = $path; $this->domain = $domain; $this->secure = $secure; } /** * @inheritDoc */ public function offsetExists(mixed $offset): bool { return isset($_COOKIE[$offset]); } /** * @inheritDoc */ public function offsetGet(mixed $offset): mixed { return $_COOKIE[$offset] ?? null; } /** * @inheritDoc */ public function offsetSet(mixed $offset, mixed $value): void { $success = setcookie($offset, $value, time() + $this->ttl, $this->path, $this->domain, $this->secure, true); if (!$success) { throw new \RuntimeException("Failed to set cookie '$offset'"); } $_COOKIE[$offset] = $value; } /** * @inheritDoc */ public function offsetUnset(mixed $offset): void { setcookie($offset, '', 1, $this->path, $this->domain, $this->secure, true); unset($_COOKIE[$offset]); } } ================================================ FILE: src/Broker/Curl.php ================================================ |string $data Query or post parameters * @return array{httpCode:int,contentType:string,body:string} * @throws RequestException */ public function request(string $method, string $url, array $headers, $data = '') { $ch = curl_init($url); if ($ch === false) { throw new \RuntimeException("Failed to initialize a cURL session"); } if ($data !== [] && $data !== '') { $post = is_string($data) ? $data : http_build_query($data); curl_setopt($ch, CURLOPT_POSTFIELDS, $post); } curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); $responseBody = (string)curl_exec($ch); if (curl_errno($ch) != 0) { throw new RequestException('Server request failed: ' . curl_error($ch)); } $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? 'text/html'; return ['httpCode' => $httpCode, 'contentType' => $contentType, 'body' => $responseBody]; } } ================================================ FILE: src/Broker/NotAttachedException.php ================================================ * @codeCoverageIgnore */ class Session implements \ArrayAccess { /** * @inheritDoc */ public function offsetSet($name, $value): void { $_SESSION[$name] = $value; } /** * @inheritDoc */ public function offsetUnset($name): void { unset($_SESSION[$name]); } /** * @inheritDoc */ public function offsetGet($name) { return $_SESSION[$name] ?? null; } /** * @inheritDoc */ public function offsetExists($name): bool { return isset($_SESSION[$name]); } } ================================================ FILE: src/Server/BrokerException.php ================================================ */ protected $options; /** * Class constructor. * * @param array $options Options passed to session_start(). */ public function __construct(array $options = []) { $this->options = $options + ['cookie_samesite' => 'None', 'cookie_secure' => true]; } /** * @inheritDoc */ public function getId(): string { return session_id(); } /** * @inheritDoc */ public function start(): void { $started = session_status() !== PHP_SESSION_ACTIVE ? session_start($this->options) : true; if (!$started) { $err = error_get_last() ?? ['message' => 'Failed to start session']; throw new ServerException($err['message'], 500); } // Session shouldn't be empty when resumed. $_SESSION['_sso_init'] = 1; } /** * @inheritDoc */ public function resume(string $id): void { session_id($id); $started = session_start($this->options); if (!$started) { $err = error_get_last() ?? ['message' => 'Failed to start session']; throw new ServerException($err['message'], 500); } if ($_SESSION === []) { session_abort(); throw new BrokerException("Session has expired. Client must attach with new token.", 401); } } /** * @inheritDoc */ public function isActive(): bool { return session_status() === PHP_SESSION_ACTIVE; } } ================================================ FILE: src/Server/Server.php ================================================ getBrokerInfo = \Closure::fromCallable($getBrokerInfo); $this->cache = $cache; $this->logger = new NullLogger(); $this->session = new GlobalSession(); } /** * Get a copy of the service with logging. * * @return static */ public function withLogger(LoggerInterface $logger): self { return $this->withProperty('logger', $logger); } /** * Get a copy of the service with a custom session service. * * @return static */ public function withSession(SessionInterface $session): self { return $this->withProperty('session', $session); } /** * Start the session for broker requests to the SSO server. * * @throws BrokerException * @throws ServerException */ public function startBrokerSession(?ServerRequestInterface $request = null): void { if ($this->session->isActive()) { throw new ServerException("Session is already started", 500); } $bearer = $this->getBearerToken($request); [$brokerId, $token, $checksum] = $this->parseBearer($bearer); $sessionId = $this->cache->get($this->getCacheKey($brokerId, $token)); if ($sessionId === null) { $this->logger->warning( "Bearer token isn't attached to a client session", ['broker' => $brokerId, 'token' => $token] ); throw new BrokerException("Bearer token isn't attached to a client session", 403); } $code = $this->getVerificationCode($brokerId, $token, $sessionId); $this->validateChecksum($checksum, 'bearer', $brokerId, $token, $code); $this->session->resume($sessionId); $this->logger->debug( "Broker request with session", ['broker' => $brokerId, 'token' => $token, 'session' => $sessionId] ); } /** * Get bearer token from Authorization header. */ protected function getBearerToken(?ServerRequestInterface $request = null): string { $authorization = $request === null ? ($_SERVER['HTTP_AUTHORIZATION'] ?? '') // @codeCoverageIgnore : $request->getHeaderLine('Authorization'); [$type, $token] = explode(' ', $authorization, 2) + ['', '']; if ($type !== 'Bearer') { $this->logger->warning("Broker didn't use bearer authentication: " . ($authorization === '' ? "No 'Authorization' header" : "$type authorization used")); throw new BrokerException("Broker didn't use bearer authentication", 401); } return $token; } /** * Get the broker id and token from the bearer token used by the broker. * * @return string[] * @throws BrokerException */ protected function parseBearer(string $bearer): array { $matches = null; if (!(bool)preg_match('/^SSO-(\w*+)-(\w*+)-([a-z0-9]*+)$/', $bearer, $matches)) { $this->logger->warning("Invalid bearer token", ['bearer' => $bearer]); throw new BrokerException("Invalid bearer token", 403); } return array_slice($matches, 1); } /** * Generate cache key for linking the broker token to the client session. */ protected function getCacheKey(string $brokerId, string $token): string { return "SSO-{$brokerId}-{$token}"; } /** * Get the broker secret using the configured callback. * * @param string $brokerId * @return string|null */ protected function getBrokerSecret(string $brokerId): ?string { return ($this->getBrokerInfo)($brokerId)['secret'] ?? null; } /** * Generate the verification code based on the token using the server secret. */ protected function getVerificationCode(string $brokerId, string $token, string $sessionId): string { return base_convert(hash('sha256', $brokerId . $token . $sessionId), 16, 36); } /** * Generate checksum for a broker. */ protected function generateChecksum(string $command, string $brokerId, string $token): string { $secret = $this->getBrokerSecret($brokerId); if ($secret === null) { $this->logger->warning("Unknown broker", ['broker' => $brokerId, 'token' => $token]); throw new BrokerException("Broker is unknown or disabled", 403); } return base_convert(hash_hmac('sha256', $command . ':' . $token, $secret), 16, 36); } /** * Assert that the checksum matches the expected checksum. * * @throws BrokerException */ protected function validateChecksum( string $checksum, string $command, string $brokerId, string $token, ?string $code = null ): void { $expected = $this->generateChecksum($command . ($code !== null ? ":$code" : ''), $brokerId, $token); if ($checksum !== $expected) { $this->logger->warning( "Invalid $command checksum", ['expected' => $expected, 'received' => $checksum, 'broker' => $brokerId, 'token' => $token] + ($code !== null ? ['verification_code' => $code] : []) ); throw new BrokerException("Invalid $command checksum", 403); } } /** * Validate that the URL has a domain that is allowed for the broker. */ public function validateDomain(string $type, string $url, string $brokerId, ?string $token = null): void { $domains = ($this->getBrokerInfo)($brokerId)['domains'] ?? []; $host = parse_url($url, PHP_URL_HOST); if (!in_array($host, $domains, true)) { $this->logger->warning( "Domain of $type is not allowed for broker", [$type => $url, 'broker' => $brokerId] + ($token !== null ? ['token' => $token] : []) ); throw new BrokerException("Domain of $type is not allowed", 400); } } /** * Attach a client session to a broker session. * Returns the verification code. * * @throws BrokerException * @throws ServerException */ public function attach(?ServerRequestInterface $request = null): string { ['broker' => $brokerId, 'token' => $token] = $this->processAttachRequest($request); $this->session->start(); $this->assertNotAttached($brokerId, $token); $key = $this->getCacheKey($brokerId, $token); $cached = $this->cache->set($key, $this->session->getId()); $info = ['broker' => $brokerId, 'token' => $token, 'session' => $this->session->getId()]; if (!$cached) { $this->logger->error("Failed to attach bearer token to session id due to cache issue", $info); throw new ServerException("Failed to attach bearer token to session id", 500); } $this->logger->info("Attached broker token to session", $info); return $this->getVerificationCode($brokerId, $token, $this->session->getId()); } /** * Assert that the token isn't already attached to a different session. */ protected function assertNotAttached(string $brokerId, string $token): void { $key = $this->getCacheKey($brokerId, $token); $attached = $this->cache->get($key); if ($attached !== null && $attached !== $this->session->getId()) { $this->logger->warning("Token is already attached", [ 'broker' => $brokerId, 'token' => $token, 'attached_to' => $attached, 'session' => $this->session->getId() ]); throw new BrokerException("Token is already attached", 400); } } /** * Validate attach request and return broker id and token. * * @param ServerRequestInterface|null $request * @return array{broker:string,token:string} * @throws BrokerException */ protected function processAttachRequest(?ServerRequestInterface $request): array { $brokerId = $this->getRequiredQueryParam($request, 'broker'); $token = $this->getRequiredQueryParam($request, 'token'); $checksum = $this->getRequiredQueryParam($request, 'checksum'); $this->validateChecksum($checksum, 'attach', $brokerId, $token); $origin = $this->getHeader($request, 'Origin'); if ($origin !== '') { $this->validateDomain('origin', $origin, $brokerId, $token); } $referer = $this->getHeader($request, 'Referer'); if ($referer !== '') { $this->validateDomain('referer', $referer, $brokerId, $token); } $returnUrl = $this->getQueryParam($request, 'return_url'); if ($returnUrl !== null) { $this->validateDomain('return_url', $returnUrl, $brokerId, $token); } return ['broker' => $brokerId, 'token' => $token]; } /** * Get query parameter from PSR-7 request or $_GET. */ protected function getQueryParam(?ServerRequestInterface $request, string $key): ?string { $params = $request === null ? $_GET // @codeCoverageIgnore : $request->getQueryParams(); return $params[$key] ?? null; } /** * Get required query parameter from PSR-7 request or $_GET. * * @throws BrokerException if query parameter isn't set */ protected function getRequiredQueryParam(?ServerRequestInterface $request, string $key): string { $value = $this->getQueryParam($request, $key); if ($value === null) { throw new BrokerException("Missing '$key' query parameter", 400); } return $value; } /** * Get HTTP Header from PSR-7 request or $_SERVER. * * @param ServerRequestInterface $request * @param string $key * @return string */ protected function getHeader(?ServerRequestInterface $request, string $key): string { return $request === null ? ($_SERVER['HTTP_' . str_replace('-', '_', strtoupper($key))] ?? '') // @codeCoverageIgnore : $request->getHeaderLine($key); } } ================================================ FILE: src/Server/ServerException.php ================================================ server = new PhpBuiltInServer(ROOT_DIR . '/demo/server/', 8200); $this->broker1 = new PhpBuiltInServer( ROOT_DIR . '/demo/broker/', 8201, [ 'SSO_SERVER' => 'http://localhost:8200/attach.php', 'SSO_BROKER_ID' => 'Alice', 'SSO_BROKER_SECRET' => '8iwzik1bwd' ] ); $this->broker2 = new PhpBuiltInServer( ROOT_DIR . '/demo/broker/', 8202, [ 'SSO_SERVER' => 'http://localhost:8200/attach.php', 'SSO_BROKER_ID' => 'Greg', 'SSO_BROKER_SECRET' => '7pypoox2pc' ] ); } /** * Hook runs after all test of the suite is run */ public function _afterSuite() { $this->server = null; $this->broker1 = null; $this->broker2 = null; parent::_afterSuite(); } /** * Set URL of broker as base host. * * @param int $nr */ public function amOnBroker(int $nr): void { if ($nr < 1 || $nr > 2) { throw new \Exception("Invalid broker number $nr"); } $port = $nr + 8200; /** @var PhpBrowser $phpBrowser */ $phpBrowser =$this->getModule('PhpBrowser'); $phpBrowser->amOnUrl("http://localhost:$port"); } } ================================================ FILE: tests/_support/Helper/Unit.php ================================================ port = $port; $this->run($documentRoot, $env); $this->testConnection(); } /** * Start the web server * * @param string $documentRoot Path to router file. * @param string[] $env Environment variables */ protected function run(string $documentRoot, array $env): void { if ($this->handle) { trigger_error("Built-in webserver on port {$this->port} already started", E_USER_NOTICE); return; } $cmd = $this->getCommand($documentRoot); $descriptorSpec = [ ["pipe", "r"], ['file', Configuration::logDir() . "phpbuiltinserver.{$this->port}.output.txt", 'w'], ['file', Configuration::logDir() . "phpbuiltinserver.{$this->port}.errors.txt", 'a'] ]; $pipes = []; $this->handle = proc_open($cmd, $descriptorSpec, $this->pipes, ROOT_DIR, $env, ['bypass_shell' => true]); fclose($this->pipes[0]); // close stdin $this->registerShutdown(); usleep(10000); $status = proc_get_status($this->handle); if (!$status['running']) { proc_close($this->handle); $error = stream_get_contents($pipes[2]) ?: stream_get_contents($pipes[1]); throw new \Exception("Failed to start PHP built-in web server. $error"); } } /** * Get the executable command to start the webserver. */ protected function getCommand(string $documentRoot): string { // Platform uses POSIX process handling. Use exec to avoid controlling the shell process instead of the PHP // interpreter. $exec = (PHP_OS !== 'WINNT' && PHP_OS !== 'WIN32') ? 'exec ' : ''; return $exec . escapeshellcmd(PHP_BINARY) . " -S localhost:{$this->port}" . " -t " . escapeshellarg($documentRoot) . ($this->isRemoteDebug() ? ' -dxdebug.remote_enable=1' : ''); } /** * Check if codeception remote debugging is available. */ protected function isRemoteDebug(): bool { return Configuration::isExtensionEnabled('Codeception\Extension\RemoteDebug'); } /** * Make sure we can connect to the webserver */ protected function testConnection() { for ($i=0; $i < 5; $i++) { if ($this->connect()) { return; } sleep(1); } $err = error_get_last(); throw new \Exception("Failed to connect to built-in web server: {$err['message']}"); } /** * Connect to the webserver */ protected function connect(): bool { $sock = @fsockopen('localhost', $this->port, $errno, $errstr, 1); return is_resource($sock) && $errno === 0; } /** * Stop the web server */ public function __destruct() { $this->stop(); } /** * Stop the web server */ public function stop(): void { if ($this->handle === null) { return; } foreach ($this->pipes as $pipe) { if (is_resource($pipe)) { fclose($pipe); } } proc_terminate($this->handle, 15); unset($this->handle); } /** * Register shutdown function to stop webserver on an error. */ protected function registerShutdown(): void { $handle = $this->handle; register_shutdown_function(function () use ($handle) { if (is_resource($handle)) { proc_terminate($handle); } }); } } ================================================ FILE: tests/_support/UnitTester.php ================================================ wantTo("login at broker 1 and see I'm also logged in at broker 2"); // --- $I->amGoingTo("login at Alice (broker 1)"); $I->amOnBroker(1); $I->see('Alice'); $I->see('Logged out'); $I->click('Login'); $I->seeElement('form', ['action' => 'login.php']); $I->submitForm('form', [ 'username' => 'john', 'password' => 'john123' ]); $I->see('Logged in'); $I->see('John Doe'); $I->see('john.doe@example.com'); // --- $I->amGoingTo("visit Greg (broker 2)"); $I->expect("john to be logged in through SSO"); $I->amOnBroker(2); $I->see('Greg'); $I->see('Logged in'); $I->see('John Doe'); $I->see('john.doe@example.com'); // --- $I->amGoingTo("logout at Greg (broker 2)"); $I->amOnBroker(2); $I->see('Greg'); $I->click('Logout'); $I->see('Logged out'); // --- $I->amGoingTo("visit Alice (broker 1)"); $I->expect("john to be logged out through SSO"); $I->amOnBroker(1); $I->see('Alice'); $I->see('Logged out'); ================================================ FILE: tests/demo.suite.yml ================================================ actor: DemoTester modules: enabled: - \Helper\Demo - PhpBrowser: url: 'http://localhost:8201' ================================================ FILE: tests/unit/Broker/AttachTest.php ================================================ session = new \ArrayObject(); $this->curl = $this->createMock(Curl::class); $this->broker = (new Broker('https://example.com/attach', 'foo', 'bar')) ->withTokenIn($this->session) ->withCurl($this->curl); } public function testUrlValidationInConstruct() { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage("Invalid SSO server URL 'example'"); new Broker('example', 'foo', 'bar'); } public function testBrokerIdValidationInConstruct() { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage("Invalid broker id 'foo-1': must be alphanumeric"); new Broker('https://example.com', 'foo-1', 'bar'); } public function testGetBrokerId() { $this->assertEquals('foo', $this->broker->getBrokerId()); } public function testGetAttachUrl() { $url = $this->broker->getAttachUrl(); $this->assertArrayHasKey('sso_token_foo', $this->session); $token = $this->session["sso_token_foo"]; $checksum = $this->generateChecksum('attach', 'bar', $token); $this->assertEquals("https://example.com/attach?broker=foo&token=$token&checksum=$checksum", $url); $this->assertFalse($this->broker->isAttached()); } public function testGetAttachUrlWithParams() { $url = $this->broker->getAttachUrl([ 'return_url' => 'http://broker.example.com/', 'color' => 'red', ]); $this->assertArrayHasKey('sso_token_foo', $this->session); $token = $this->session["sso_token_foo"]; $checksum = $this->generateChecksum('attach', 'bar', $token); $expectedUrl = "https://example.com/attach?broker=foo&token=$token&checksum=$checksum&return_url=" . urlencode('http://broker.example.com/') . '&color=red'; $this->assertEquals($expectedUrl, $url); } public function testVerify() { $this->session['sso_token_foo'] = '123456'; $this->assertFalse($this->broker->isAttached()); $code = $this->getVerificationCode('foo', '123456', 'abc123'); $this->broker->verify($code); $this->assertArrayHasKey('sso_verify_foo', $this->session); $this->assertEquals($code, $this->session['sso_verify_foo']); $this->assertTrue($this->broker->isAttached()); } public function testVerifyIsIdempotent() { $code = $this->getVerificationCode('foo', '123456', 'abc123'); $this->session['sso_token_foo'] = '123456'; $this->session['sso_verify_foo'] = $code; $this->broker->verify($code); $this->assertArrayHasKey('sso_verify_foo', $this->session); $this->assertEquals($code, $this->session['sso_verify_foo']); } public function testVerifyIsImmutable() { $this->session['sso_token_foo'] = '123456'; $this->session['sso_verify_foo'] = '000000'; $code = $this->getVerificationCode('foo', '123456', 'abc123'); $this->expectWarningMessage("SSO attach already verified"); $this->broker->verify($code); $this->assertArrayHasKey('sso_verify_foo', $this->session); $this->assertEquals('000000', $this->session['sso_verify_foo']); } public function testClearToken() { $this->session['sso_token_foo'] = '123456'; $this->session['sso_verify_foo'] = $this->getVerificationCode('foo', '123456', 'abc123'); $this->assertTrue($this->broker->isAttached()); $this->broker->clearToken(); $this->assertFalse($this->broker->isAttached()); $this->assertArrayNotHasKey('sso_token_foo', $this->session); $this->assertArrayNotHasKey('sso_verify_foo', $this->session); } } ================================================ FILE: tests/unit/Broker/RequestTest.php ================================================ session = new \ArrayObject([ 'sso_token_foo' => '123456', 'sso_verify_foo' => $this->getVerificationCode('foo', '123456', 'abc123'), ]); $this->curl = $this->createMock(Curl::class); $this->broker = (new Broker('https://example.com/attach', 'foo', 'bar')) ->withTokenIn($this->session) ->withCurl($this->curl); } public function testGetBearerToken() { $this->assertTrue($this->broker->isAttached()); $bearer = $this->broker->getBearerToken(); $this->assertEquals( $this->getBearerToken('foo', 'bar', '123456', 'abc123'), $bearer ); } public function testGetBearerTokenWhenNotAttached() { unset($this->session['sso_verify_foo']); $this->assertFalse($this->broker->isAttached()); $this->expectException(NotAttachedException::class); $this->expectExceptionMessage("The client isn't attached to the SSO server for this broker. " . "Make sure that the 'sso_verify_foo' cookie is set."); $this->broker->getBearerToken(); } public function testGetRequest() { $headers = [ 'Accept: application/json', 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') ]; $this->curl->expects($this->once())->method('request') ->with('GET', 'https://example.com/info', $headers, '') ->willReturn([ 'httpCode' => 200, 'contentType' => 'application/json; charset=utf-8', 'body' => '{"name": "John", "email": "john@example.com"}', ]); $info = $this->broker->request('GET', '/info'); $this->assertEquals(['name' => 'John', 'email' => 'john@example.com'], $info); } public function testPostRequest() { $headers = [ 'Accept: application/json', 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') ]; $this->curl->expects($this->once())->method('request') ->with('POST', 'https://example.com/user', $headers, ['name' => 'John', 'color' => 'red']) ->willReturn([ 'httpCode' => 200, 'contentType' => 'application/json; charset=utf-8', 'body' => '{"name": "John", "email": "john@example.com"}', ]); $info = $this->broker->request('POST', '/user', ['name' => 'John', 'color' => 'red']); $this->assertEquals(['name' => 'John', 'email' => 'john@example.com'], $info); } public function testNoContent() { $headers = [ 'Accept: application/json', 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') ]; $this->curl->expects($this->once())->method('request') ->with('POST', 'https://example.com/go', $headers, '') ->willReturn([ 'httpCode' => 204, 'contentType' => '', 'body' => '', ]); $info = $this->broker->request('POST', '/go'); $this->assertNull($info); } public function testBadRequest() { $headers = [ 'Accept: application/json', 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') ]; $this->curl->expects($this->once())->method('request') ->with('GET', 'https://example.com/', $headers, '') ->willReturn([ 'httpCode' => 400, 'contentType' => 'application/json', 'body' => '{"error": "something is wrong"}', ]); $this->expectException(RequestException::class); $this->expectExceptionMessage("something is wrong"); $this->broker->request('GET', '/'); } public function testInvalidContentType() { $headers = [ 'Accept: application/json', 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') ]; $this->curl->expects($this->once())->method('request') ->with('GET', 'https://example.com/', $headers, '') ->willReturn([ 'httpCode' => 200, 'contentType' => 'text/html', 'body' => '

Foo

', ]); $this->expectException(RequestException::class); $this->expectExceptionMessage("Expected 'application/json' response, got 'text/html'"); $this->broker->request('GET', '/'); } public function testInvalidJson() { $headers = [ 'Accept: application/json', 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') ]; $this->curl->expects($this->once())->method('request') ->with('GET', 'https://example.com/', $headers, '') ->willReturn([ 'httpCode' => 200, 'contentType' => 'application/json', 'body' => 'not json', ]); $this->expectException(RequestException::class); $this->expectExceptionMessage("Invalid JSON response from server"); $this->broker->request('GET', '/'); } } ================================================ FILE: tests/unit/Server/AttachTest.php ================================================ createCallbackMock( $this->atLeastOnce(), ['foo'], ['secret' => 'bar', 'domains' => ['broker.example.com']] ); $cache = $this->createMock(CacheInterface::class); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withQueryParams([ 'broker' => 'foo', 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), 'token' => '123456', 'return_url' => 'https://broker.example.com/attached' ]) ->withHeader('Referer', 'https://broker.example.com/login') ->withHeader('Origin', 'https://broker.example.com/'); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $session->expects($this->once())->method('start')->id('start'); $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); $cache->expects($this->once())->method('get') ->with('SSO-foo-123456') ->willReturn(null); $cache->expects($this->once())->method('set') ->with('SSO-foo-123456', 'abc123') ->willReturn(true); $logger->expects($this->once())->method('info') ->with( "Attached broker token to session", ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123'] ); $code = $server->attach($request); $this->assertEquals( $this->getVerificationCode('foo', '123456', 'abc123'), $code ); } public function missingQueryParameterProvider() { return [ 'broker' => ['broker'], 'checksum' => ['checksum'], 'token' => ['token'], ]; } /** * @dataProvider missingQueryParameterProvider */ public function testMissingQueryParameter(string $key) { $callback = $this->createCallbackMock($this->never()); $cache = $this->createMock(CacheInterface::class); $queryParams = [ 'broker' => 'foo', 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), 'token' => '123456', 'return_url' => 'https://return_url.example.com/' ]; $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withQueryParams(array_without($queryParams, [$key])) ->withHeader('Referer', 'https://referer.example.com/') ->withHeader('Origin', 'https://origin.example.com/'); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $session->expects($this->never())->method('start'); $cache->expects($this->never())->method('get'); $cache->expects($this->never())->method('set'); $this->expectException(BrokerException::class); $this->expectExceptionMessage("Missing '$key' query parameter"); $server->attach($request); } public function domainProvider() { return [ 'return_url' => ['return_url', ['origin.example.com', 'referer.example.com']], 'origin' => ['origin', ['referer.example.com', 'return_url.example.com']], 'referer' => ['referer', ['origin.example.com', 'return_url.example.com']], ]; } /** * @dataProvider domainProvider */ public function testInvalidDomain(string $type, array $domains) { $callback = $this->createCallbackMock( $this->atLeastOnce(), ['foo'], ['secret' => 'bar', 'domains' => $domains] ); $cache = $this->createMock(CacheInterface::class); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withQueryParams([ 'broker' => 'foo', 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), 'token' => '123456', 'return_url' => 'https://return_url.example.com/' ]) ->withHeader('Referer', 'https://referer.example.com/') ->withHeader('Origin', 'https://origin.example.com/'); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $session->expects($this->never())->method('start'); $cache->expects($this->never())->method('get'); $cache->expects($this->never())->method('set'); $logger->expects($this->once())->method('warning') ->with( "Domain of $type is not allowed for broker", [$type => "https://$type.example.com/", 'broker' => 'foo', 'token' => '123456'] ); $this->expectException(BrokerException::class); $this->expectExceptionMessage("Domain of $type is not allowed"); $server->attach($request); } public function testInvalidChecksum() { $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); $cache = $this->createMock(CacheInterface::class); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withQueryParams([ 'broker' => 'foo', 'checksum' => '0000000000', 'token' => '123456' ]); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $cache->expects($this->never())->method('get'); $cache->expects($this->never())->method('set'); $checksum = $this->generateChecksum('attach', 'bar', '123456'); $logger->expects($this->once())->method('warning') ->with( "Invalid attach checksum", ['expected' => $checksum, 'received' => '0000000000', 'broker' => 'foo', 'token' => '123456'] ); $this->expectException(BrokerException::class); $this->expectExceptionMessage("Invalid attach checksum"); $server->attach($request); } public function testUnknownBroker() { $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], null); $cache = $this->createMock(CacheInterface::class); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withQueryParams([ 'broker' => 'foo', 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), 'token' => '123456' ]); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $cache->expects($this->never())->method('get'); $cache->expects($this->never())->method('set'); $logger->expects($this->once())->method('warning') ->with("Unknown broker", ['broker' => 'foo', 'token' => '123456']); $this->expectException(BrokerException::class); $this->expectExceptionMessage("Broker is unknown or disabled"); $server->attach($request); } public function testAlreadyAttached() { $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); $cache = $this->createMock(CacheInterface::class); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withQueryParams([ 'broker' => 'foo', 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), 'token' => '123456' ]); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $session->expects($this->once())->method('start')->id('start'); $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); $cache->expects($this->once())->method('get') ->with('SSO-foo-123456') ->willReturn('xyz543'); $cache->expects($this->never())->method('set'); $logger->expects($this->once())->method('warning') ->with( "Token is already attached", ['broker' => 'foo', 'token' => '123456', 'attached_to' => 'xyz543', 'session' => 'abc123'] ); $this->expectException(BrokerException::class); $this->expectExceptionMessage("Token is already attached"); $server->attach($request); } public function testAttachIsIdempotent() { $callback = $this->createCallbackMock( $this->atLeastOnce(), ['foo'], ['secret' => 'bar', 'domains' => ['broker.example.com']] ); $cache = $this->createMock(CacheInterface::class); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withQueryParams([ 'broker' => 'foo', 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), 'token' => '123456', 'return_url' => 'https://broker.example.com/attached' ]) ->withHeader('Referer', 'https://broker.example.com/login') ->withHeader('Origin', 'https://broker.example.com/'); $session = $this->createMock(SessionInterface::class); $server = (new Server($callback, $cache)) ->withSession($session); $session->expects($this->once())->method('start')->id('start'); $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); $cache->expects($this->once())->method('get') ->with('SSO-foo-123456') ->willReturn('abc123'); $cache->expects($this->once())->method('set') ->with('SSO-foo-123456', 'abc123') ->willReturn(true); $code = $server->attach($request); $this->assertEquals( $this->getVerificationCode('foo', '123456', 'abc123'), $code ); } public function testCacheIssue() { $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); $cache = $this->createMock(CacheInterface::class); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withQueryParams([ 'broker' => 'foo', 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), 'token' => '123456' ]); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $session->expects($this->once())->method('start')->id('start'); $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); $cache->expects($this->once())->method('get') ->with('SSO-foo-123456') ->willReturn(null); $cache->expects($this->once())->method('set') ->with('SSO-foo-123456', 'abc123') ->willReturn(false); $logger->expects($this->once())->method('error') ->with( "Failed to attach bearer token to session id due to cache issue", ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123'] ); $this->expectException(ServerException::class); $this->expectExceptionMessage("Failed to attach bearer token to session id"); $server->attach($request); } } ================================================ FILE: tests/unit/Server/BrokerSessionTest.php ================================================ createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); $cache = $this->createMock(CacheInterface::class); $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withHeader('Authorization', "Bearer $bearer"); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $cache->expects($this->once())->method('get') ->with('SSO-foo-123456') ->willReturn('abc123'); $session->expects($this->once())->method('isActive')->willReturn(false); $session->expects($this->once())->method('resume')->with('abc123'); $logger->expects($this->once())->method('debug') ->with( "Broker request with session", ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123'] ); $server->startBrokerSession($request); } public function testSessionAlreadyStarted() { $callback = $this->createCallbackMock($this->never()); $cache = $this->createMock(CacheInterface::class); $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withHeader('Authorization', "Bearer $bearer"); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $session->expects($this->once())->method('isActive')->willReturn(true); $session->expects($this->never())->method('start'); $this->expectException(ServerException::class); $this->expectExceptionMessage("Session is already started"); $server->startBrokerSession($request); } public function testMissingAuthorizationHeader() { $callback = $this->createCallbackMock($this->never()); $cache = $this->createMock(CacheInterface::class); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $session->expects($this->once())->method('isActive')->willReturn(false); $session->expects($this->never())->method('start'); $logger->expects($this->once())->method('warning') ->with("Broker didn't use bearer authentication: No 'Authorization' header"); $this->expectException(BrokerException::class); $this->expectExceptionMessage("Broker didn't use bearer authentication"); $server->startBrokerSession($request); } public function testNoBearerAuthorization() { $callback = $this->createCallbackMock($this->never()); $cache = $this->createMock(CacheInterface::class); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withHeader("Authorization", "Basic dXNlcjpwYXNz"); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $session->expects($this->once())->method('isActive')->willReturn(false); $session->expects($this->never())->method('start'); $logger->expects($this->once())->method('warning') ->with("Broker didn't use bearer authentication: Basic authorization used"); $this->expectException(BrokerException::class); $this->expectExceptionMessage("Broker didn't use bearer authentication"); $server->startBrokerSession($request); } public function testInvalidBearerToken() { $callback = $this->createCallbackMock($this->never()); $cache = $this->createMock(CacheInterface::class); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withHeader('Authorization', "Bearer 000000"); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $session->expects($this->once())->method('isActive')->willReturn(false); $session->expects($this->never())->method('start'); $session->expects($this->never())->method('resume'); $logger->expects($this->once())->method('warning') ->with("Invalid bearer token", ['bearer' => '000000']); $this->expectException(BrokerException::class); $this->expectExceptionCode(403); $this->expectExceptionMessage("Invalid bearer token"); $server->startBrokerSession($request); } public function testInvalidChecksum() { $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); $cache = $this->createMock(CacheInterface::class); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withHeader('Authorization', "Bearer SSO-foo-123456-000000"); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $cache->expects($this->once())->method('get') ->with('SSO-foo-123456') ->willReturn('abc123'); $session->expects($this->once())->method('isActive')->willReturn(false); $session->expects($this->never())->method('start'); $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); $logger->expects($this->once())->method('warning') ->with( "Invalid bearer checksum", [ 'expected' => str_replace('SSO-foo-123456-', '', $bearer), 'received' => '000000', 'broker' => 'foo', 'token' => '123456', 'verification_code' => $this->getVerificationCode('foo', '123456', 'abc123') ] ); $this->expectException(BrokerException::class); $this->expectExceptionCode(403); $this->expectExceptionMessage("Invalid bearer checksum"); $server->startBrokerSession($request); } public function testUnattachedToken() { $callback = $this->createCallbackMock($this->any(), ['foo'], ['secret' => 'bar']); $cache = $this->createMock(CacheInterface::class); $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withHeader('Authorization', "Bearer $bearer"); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $cache->expects($this->once())->method('get') ->with('SSO-foo-123456') ->willReturn(null); $session->expects($this->once())->method('isActive')->willReturn(false); $session->expects($this->never())->method('start'); $logger->expects($this->once())->method('warning') ->with( "Bearer token isn't attached to a client session", ['broker' => 'foo', 'token' => '123456'] ); $this->expectException(BrokerException::class); $this->expectExceptionCode(403); $this->expectExceptionMessage("Bearer token isn't attached to a client session"); $server->startBrokerSession($request); } public function testUnknownBroker() { $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], null); $cache = $this->createMock(CacheInterface::class); $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); $request = (new ServerRequest()) ->withUri(new Uri("https://server.example.com/attach.php")) ->withHeader('Authorization', "Bearer $bearer"); $session = $this->createMock(SessionInterface::class); $logger = $this->createMock(LoggerInterface::class); $server = (new Server($callback, $cache)) ->withSession($session) ->withLogger($logger); $cache->expects($this->once())->method('get') ->with('SSO-foo-123456') ->willReturn('abc123'); $session->expects($this->once())->method('isActive')->willReturn(false); $session->expects($this->never())->method('start'); $logger->expects($this->once())->method('warning') ->with("Unknown broker", ['broker' => 'foo', 'token' => '123456']); $this->expectException(BrokerException::class); $this->expectExceptionCode(403); $this->expectExceptionMessage("Broker is unknown or disabled"); $server->startBrokerSession($request); } } ================================================ FILE: tests/unit/TokenTrait.php ================================================ getVerificationCode($broker, $token, $sessionId); return "SSO-{$broker}-{$token}-" . $this->generateChecksum("bearer:$code", $secret, $token); } } ================================================ FILE: tests/unit.suite.yml ================================================ actor: UnitTester modules: enabled: - \Helper\Unit coverage: enabled: true include: - src/*