[
  {
    "path": ".gitattributes",
    "content": "/demo export-ignore\n/tests export-ignore\n/.gitattributes export-ignore\n/.gitignore export-ignore\n/.scrutinizer.yml export-ignore\n/.travis.yml export-ignore\n/phpunit.xml.dist export-ignore\n/phpcs.xml.dist export-ignore\n/phpstan.neon export-ignore\n/README.md export-ignore\n"
  },
  {
    "path": ".github/workflows/php.yml",
    "content": "name: PHP\n\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    branches: [ master ]\n\njobs:\n  run:\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - php: 8.0\n          - php: 8.1\n          - php: 8.2\n            coverage: '--coverage --coverage-xml'\n    name: PHP ${{ matrix.php }}\n\n    steps:\n    - uses: actions/checkout@v2\n      with:\n        fetch-depth: 10\n \n    - name: Setup PHP\n      uses: shivammathur/setup-php@v2\n      with:\n        php-version: ${{ matrix.php }}\n        coverage: xdebug\n\n    - name: Validate composer.json\n      run: composer validate\n\n    - name: Install dependencies\n      run: composer update --prefer-dist --no-progress --no-suggest\n\n    - name: Run Codeception\n      run: vendor/bin/codecept run ${{ matrix.coverage }}\n\n    - name: Upload coverage to Scrutinizer\n      if: ${{ matrix.coverage }}\n      uses: sudo-bot/action-scrutinizer@latest\n      with:\n        cli-args: \"--format=php-clover build/logs/clover.xml --revision=${{ github.event.pull_request.head.sha || github.sha }}\"\n\n"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\nnbproject\n/vendor\ncomposer.lock\n\ntests/_output/*\n# Elastic Beanstalk Files\n.elasticbeanstalk/*\n!.elasticbeanstalk/*.cfg.yml\n!.elasticbeanstalk/*.global.yml\n/tests/_support/_generated/\n.idea\n"
  },
  {
    "path": ".scrutinizer.yml",
    "content": "#language: php\nchecks:\n  php: true\nfilter:\n  excluded_paths:\n    - tests\nbuild:\n  nodes:\n    analysis:\n      environment:\n        php: 8.2\n        postgresql: false\n        redis: false\n        mongodb: false\n      tests:\n        override:\n            - phpcs-run src\n            -\n                command: vendor/bin/phpstan analyze --error-format=checkstyle | sed '/^\\s*$/d' > phpstan-checkstyle.xml\n                analysis:\n                    file: phpstan-checkstyle.xml\n                    format: 'general-checkstyle'\n            - php-scrutinizer-run\n\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright (c) 2020 Arnold Daniels\n\nPermission is hereby granted, free of charge, to any person\nobtaining a copy of this software and associated documentation\nfiles (the \"Software\"), to deal in the Software without\nrestriction, including without limitation the rights to use,\ncopy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the\nSoftware is furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\nOF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\nHOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nWHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "![jasny-banner](https://user-images.githubusercontent.com/100821/62123924-4c501c80-b2c9-11e9-9677-2ebc21d9b713.png)\n\nSingle Sign-On for PHP\n========\n\n[![PHP](https://github.com/jasny/sso/workflows/PHP/badge.svg)](https://github.com/jasny/sso/actions)\n[![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)\n[![Code Coverage](https://scrutinizer-ci.com/g/jasny/sso/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/jasny/sso/?branch=master)\n[![Packagist Stable Version](https://img.shields.io/packagist/v/jasny/sso.svg)](https://packagist.org/packages/jasny/sso)\n[![Packagist License](https://img.shields.io/packagist/l/jasny/sso.svg)](https://packagist.org/packages/jasny/sso)\n\nJasny SSO is a relatively simple and straightforward solution for single sign on (SSO).\n\nWith SSO, logging into a single website will authenticate you for all affiliate sites. The sites don't need to share a\ntoplevel domain.\n\n### How it works\n\nWhen using SSO, we can distinguish 3 parties:\n\n* Client - This is the browser of the visitor\n* Broker - The website which is visited\n* Server - The place that holds the user info and credentials\n\nThe broker has an id and a secret. These are known to both the broker and server.\n\nWhen the client visits the broker, it creates a random token, which is stored in a cookie. The broker will then send\nthe client to the server, passing along the broker's id and token. The server creates a hash using the broker id, broker\nsecret and the token. This hash is used to create a link to the user's session. When the link is created the server\nredirects the client back to the broker.\n\nThe broker can create the same link hash using the token (from the cookie), the broker id and the broker secret. When\ndoing requests, it passes that hash as a session id.\n\nThe server will notice that the session id is a link and use the linked session. As such, the broker and client are\nusing the same session. When another broker joins in, it will also use the same session.\n\nFor a more in depth explanation, please [read this article](https://github.com/jasny/sso/wiki).\n\n### How is this different from OAuth?\n\nWith OAuth, you can authenticate a user at an external server and get access to their profile info. However, you\naren't sharing a session.\n\nA user logs in to website foo.com using Google OAuth. Next they visit website bar.org which also uses Google OAuth.\nRegardless of that, they are still required to press the 'login' button on bar.org.\n\nWith Jasny SSO both websites use the same session. So when the user visits bar.org, they are automatically logged in.\nWhen they log out (on either of the sites), they are logged out for both.\n\n## Installation\n\nInstall this library through composer\n\n    composer require jasny/sso\n\n## Demo\n\nThere is a demo server and two demo brokers as example. One with normal redirects and one using\n[JSONP](https://en.wikipedia.org/wiki/JSONP) / AJAX.\n\nTo prove it's working you should setup the server and two or more brokers, each on their own machine and their own\n(sub)domain. However, you can also run both server and brokers on your own machine, simply to test it out.\n\nOn *nix (Linux / Unix / OSX) run:\n\n    php -S localhost:8000 -t demo/server/\n    export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Alice SSO_BROKER_SECRET=8iwzik1bwd; php -S localhost:8001 -t demo/broker/\n    export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Greg SSO_BROKER_SECRET=7pypoox2pc; php -S localhost:8002 -t demo/broker/\n    export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Julius SSO_BROKER_SECRET=ceda63kmhp; php -S localhost:8003 -t demo/ajax-broker/\n\nNow open some tabs and visit \n\n  * http://localhost:8001\n  * http://localhost:8002\n  * http://localhost:8003\n\nusername | password\n-------- | --------\njackie   | jackie123\njohn     | john123\n\n_Note that after logging in, you need to refresh on the other brokers to see the effect._\n\n# Usage\n\n## Server\n\nThe `Server` class takes a callback as first constructor argument. This callback should look up the secret\nfor a broker based on the id.\n\nThe second argument must be a PSR-16 compatible cache object. It's used to store the link between broker token and\nclient session.\n\n```php\nuse Jasny\\SSO\\Server\\Server;\n\n$brokers = [\n    'foo' => ['secret' => '8OyRi6Ix1x', 'domains' => ['example.com']],\n    // ...\n];\n\n$server = new Server(\n    fn($id) => $brokers[$id] ?? null, // Unique secret and allowed domains for each broker.\n    new Cache()                       // Any PSR-16 compatible cache\n);\n```\n\n_In this example the brokers are simply configured as an array, but typically you want to fetch the broker info from a DB._\n\n### Attach\n\nA client needs to attach the broker token to the session id by doing an HTTP request to the server. This request can be\nhandled by calling `attach()`.\n\nThe `attach()` method returns a verification code. This code must be returned to the broker, as it's needed to\ncalculate the checksum.\n\n```php\n$verificationCode = $server->attach();\n```\n\nIf it's not possible to attach (for instance in case of an incorrect checksum), an Exception is thrown.\n\n### Handle broker API request\n\nAfter the client session is attached to the broker token, the broker is able to send API requests on behalf of the\nclient. Calling the `startBrokerSession()` method with start the session of the client based on the bearer token. This\nmeans that these request the server can access the session information of the client through `$_SESSION`.\n\n```\n$server->startBrokerSession();\n```\n\nThe broker could use this to login, logout, get user information, etc. The API for handling such requests is outside\nthe scope of the project. However since the broker uses normal sessions, any existing the authentication can be used.\n\n_If you're lookup for an authentication library, consider using [Jasny Auth](https://github.com/jasny/auth)._\n\n### PSR-7\n\nBy default, the library works with superglobals like `$_GET` and `$_SERVER`. Alternatively it can use a PSR-7 server\nrequest. This can be passed to `attach()` and `startBrokerSession()` as argument.\n\n```php\n$verificationCode = $server->attach($serverRequest);\n```\n\n### Session interface\n\nBy default, the library uses the superglobal `$_SESSION` and the `php_session_*()` functions. It does this through\nthe `GlobalSession` object, which implements `SessionInterface`.\n\nFor projects that use alternative sessions, it's possible to create a wrapper that implements `SessionInterface`.\n\n```php\nuse Jasny\\SSO\\Server\\SessionInterface;\n\nclass CustomerSessionHandler implements SessionInterface\n{\n    // ...\n}\n```\n\nThe `withSession()` methods creates a copy of the Server object with the custom session interface.\n\n```php\n$server = (new Server($callback, $cache))\n    ->withSession(new CustomerSessionHandler());\n```\n\nThe `withSession()` method can also be used with a mock object for testing.\n\n### Logging\n\nEnable logging for debugging and catching issues.\n\n```php\n$server = (new Server($callback, $cache))\n    ->withLogging(new Logger());\n``` \n\nAny PSR-3 compatible logger can be used, like [Monolog](https://packagist.org/packages/monolog/monolog) or\n[Loggy](https://packagist.org/packages/yubb/loggy). The `context` may contain the broker id, token, and session id.\n\n## Broker\n\nWhen creating a `Broker` instance, you need to pass the server url, broker id and broker secret. The broker id and\nsecret needs to match the secret registered at the server.\n\n**CAVEAT**: *The broker id MUST be alphanumeric.*\n\n### Attach\n\nBefore the broker can do API requests on the client's behalf, the client needs to attach the broker token to the client\nsession. For this, the client must do an HTTP request to the SSO Server.\n\nThe `getAttachUrl()` method will generate a broker token for the client and use it to create an attach URL. The method\ntakes an array of query parameters as single argument.\n\nThere are several methods in making the client do an HTTP request. The broker can redirect the client or do a request\nvia the browser using AJAX or loading an image.\n\n```php\nuse Jasny\\SSO\\Broker\\Broker;\n\n// Configure the broker.\n$broker = new Broker(\n    getenv('SSO_SERVER'),\n    getenv('SSO_BROKER_ID'),\n    getenv('SSO_BROKER_SECRET')\n);\n\n// Attach through redirect if the client isn't attached yet.\nif (!$broker->isAttached()) {\n    $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];\n    $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]);\n\n    header(\"Location: $attachUrl\", true, 303);\n    echo \"You're redirected to <a href='$attachUrl'>$attachUrl</a>\";\n    exit();\n}\n```\n\n### Verify\n\nUpon verification the SSO Server will return a verification code (as a query parameter or in the JSON response). The code\nis used to calculate the checksum. The verification code prevents session hijacking using an attach link.\n\n```php\nif (isset($_GET['sso_verify'])) {\n    $broker->verify($_GET['sso_verify']);\n}\n```\n\n### API requests\n\nOnce attached, the broker is able to do API requests on behalf of the client. This can be done by\n\n- using the broker `request()` method, or by\n- using any HTTP client like Guzzle\n\n#### Broker request\n\n```php\n// Post to modify the user info\n$broker->request('POST', '/login', $credentials);\n\n// Get user info\n$user = $broker->request('GET', '/user');\n```\n\nThe `request()` method uses Curl to send HTTP requests, adding the bearer token for authentication. It expects a JSON\nresponse and will automatically decode it.\n\n#### HTTP library (Guzzle)\n\nTo use a library like [Guzzle](http://docs.guzzlephp.org/) or [Httplug](http://httplug.io/), get the bearer token using\n`getBearerToken()` and set the `Authorization` header\n    \n```php\n$guzzle = new GuzzleHttp\\Client(['base_uri' => 'https://sso-server.example.com']);\n\n$res = $guzzle->request('GET', '/user', [\n    'headers' => [\n        'Authorization' => 'Bearer ' . $broker->getBearerToken()\n    ]\n]);\n```\n\n### Client state\n\nBy default, the Broker uses the cookies (`$_COOKIE` and `setcookie()`) via the `Cookies` class to persist the client's\nSSO token.\n\n#### Cookie\n\nInstantiate a new `Cookies` object with custom parameters to modify things like cookie TTL, domain and https only.\n\n```php\nuse Jasny\\SSO\\Broker\\{Broker,Cookies};\n\n$broker = (new Broker(getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET')))\n    ->withTokenIn(new Cookies(7200, '/myapp', 'example.com', true));\n```\n\n_(The cookie can never be accessed by the browser.)_\n\n#### Session\n\nAlternatively, you can store the SSO token in a PHP session for the broker by using `Session`.\n\n```php\nuse Jasny\\SSO\\Broker\\{Broker,Session};\n\nsession_start();\n\n$broker = (new Broker(getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET')))\n    ->withTokenIn(new Session());\n```\n\n#### Custom\n\nThe method accepts any object that implements `ArrayAccess`, allowing you to create a custom handler if needed.\n\n```php\nclass CustomStateHandler implements \\ArrayAccess\n{\n    // ...\n}\n```\n\nThis can also be used with a mock object for testing. \n"
  },
  {
    "path": "codeception.yml",
    "content": "actor: Tester\npaths:\n    tests: tests\n    log: tests/_output\n    data: tests/_data\n    support: tests/_support\n    envs: tests/_envs\nbootstrap: _bootstrap.php\nsettings:\n    colors: true\n    memory_limit: 1024M\nextensions:\n    enabled:\n        - Codeception\\Extension\\RunFailed\n"
  },
  {
    "path": "composer.json",
    "content": "{\n    \"name\": \"jasny/sso\",\n    \"description\": \"Simple Single Sign-On\",\n    \"keywords\": [\"sso\", \"auth\"],\n    \"license\": \"MIT\",\n    \"homepage\": \"https://github.com/jasny/sso/wiki\",\n    \"authors\": [\n        {\n            \"name\": \"Arnold Daniels\",\n            \"email\": \"arnold@jasny.net\",\n            \"homepage\": \"http://www.jasny.net\"\n        }\n    ],\n    \"support\": {\n        \"issues\": \"https://github.com/jasny/sso/issues\",\n        \"source\": \"https://github.com/jasny/sso\"\n    },\n    \"require\": {\n        \"php\": \"^8.0\",\n        \"ext-json\": \"*\",\n        \"jasny/immutable\": \"^2.1\",\n        \"psr/simple-cache\": \"*\",\n        \"psr/log\": \"*\"\n    },\n    \"require-dev\": {\n        \"phpstan/phpstan\": \"^0.12.59\",\n        \"codeception/codeception\": \"^4.1\",\n        \"codeception/module-phpbrowser\": \"^1.0\",\n        \"codeception/module-rest\": \"^1.2\",\n        \"desarrolla2/cache\": \"^3.0\",\n        \"jasny/http-message\": \"^1.3\",\n        \"jasny/php-code-quality\": \"^2.6.0\",\n        \"jasny/phpunit-extension\": \"^0.3.2\",\n        \"yubb/loggy\": \"^2.1\"\n    },\n    \"autoload\": {\n        \"psr-4\": {\n            \"Jasny\\\\SSO\\\\\": \"src/\"\n        }\n    },\n    \"autoload-dev\": {\n        \"psr-4\": {\n            \"Jasny\\\\Tests\\\\SSO\\\\\": \"tests/unit/\"\n        }\n    },\n    \"scripts\": {\n        \"test\": [\n            \"phpstan analyse\",\n            \"codecept run\",\n            \"phpcs -p src\"\n        ]\n    },\n    \"config\": {\n        \"preferred-install\": \"dist\",\n        \"sort-packages\": true,\n        \"optimize-autoloader\": true\n    },\n    \"minimum-stability\": \"dev\",\n    \"prefer-stable\": true\n}\n"
  },
  {
    "path": "demo/ajax-broker/api.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Broker\\Broker;\n\nrequire_once __DIR__ . '/../../vendor/autoload.php';\n\n// Configure the broker.\n$broker = new Broker(\n    getenv('SSO_SERVER'),\n    getenv('SSO_BROKER_ID'),\n    getenv('SSO_BROKER_SECRET')\n);\n\ntry {\n    $path = '/api/' . $_GET['command'] . '.php';\n    $result = $broker->request($_SERVER['REQUEST_METHOD'], $path, $_POST);\n} catch (Exception $e) {\n    $status = $e->getCode() ?: 500;\n    $result = ['error' => $e->getMessage()];\n}\n\n// REST\nif (!$result) {\n    http_response_code(204);\n} else {\n    http_response_code(isset($status) ? $status : 200);\n    header(\"Content-Type: application/json\");\n    echo json_encode($result);\n}\n"
  },
  {
    "path": "demo/ajax-broker/app.js",
    "content": "+function ($) {\n    // Init\n    attach();\n\n    /**\n     * Attach session.\n     * Will redirect to SSO server.\n     */\n    function attach()\n    {\n        const req = $.ajax({\n            url: 'attach.php',\n            crossDomain: true,\n            dataType: 'jsonp'\n        });\n\n        req.done(function (data, code) {\n            if (code && code >= 400) { // jsonp failure\n                showError(data);\n                return;\n            }\n\n            $.ajax({method: 'POST', url: 'verify.php', data: data}).done(function () {\n                doApiRequest('info', null, showUserInfo);\n            });\n        });\n\n        req.fail(function (jqxhr) {\n            showError(jqxhr.responseJSON || jqxhr.textResponse)\n        });\n    }\n\n    /**\n     * Do an AJAX request to the API\n     *\n     * @param command   API command\n     * @param params    POST data\n     * @param callback  Callback function\n     */\n    function doApiRequest(command, params, callback)\n    {\n        const req = $.ajax({\n            url: 'api.php?command=' + command,\n            method: params ? 'POST' : 'GET',\n            data: params,\n            dataType: 'json'\n        });\n\n        req.done(callback);\n\n        req.fail(function (jqxhr) {\n            showError(jqxhr.responseJSON || jqxhr.textResponse);\n        });\n    }\n\n    /**\n     * Display the error message\n     *\n     * @param data\n     */\n    function showError(data)\n    {\n        const message = typeof data === 'object' && data.error ? data.error : 'Unexpected error';\n        $.growl.error({message: message});\n    }\n\n    /**\n     * Display user info\n     *\n     * @param info\n     */\n    function showUserInfo(info)\n    {\n        const body = $('body');\n        const userInfo = $('#user-info');\n\n        body.removeClass('anonymous authenticated');\n        userInfo.html('');\n\n        if (info) {\n            for (const key in info) {\n                userInfo.append($('<dt>').text(key));\n                userInfo.append($('<dd>').text(info[key]));\n            }\n        }\n\n        body.addClass(info ? 'authenticated' : 'anonymous');\n    }\n\n    /**\n     * Submit login form through AJAX\n     */\n    $('#login-form').on('submit', function (e) {\n        e.preventDefault();\n\n        $('#error').text('').hide();\n\n        var data = {\n            username: this.username.value,\n            password: this.password.value\n        };\n\n        doApiRequest('login', data, showUserInfo);\n    });\n\n    $('#logout').on('click', function () {\n        doApiRequest('logout', {}, () => showUserInfo(null));\n    });\n}(jQuery);\n"
  },
  {
    "path": "demo/ajax-broker/attach.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Broker\\Broker;\n\nrequire_once __DIR__ . '/../../vendor/autoload.php';\n\n// Configure the broker.\n$broker = new Broker(\n    getenv('SSO_SERVER'),\n    getenv('SSO_BROKER_ID'),\n    getenv('SSO_BROKER_SECRET')\n);\n\n$jsCallback = $_GET['callback'];\n\n// Already attached\nif ($broker->isAttached()) {\n    echo $jsCallback . '(null, 200)';\n}\n\n// Attach through redirect if the client isn't attached yet.\n$url = $broker->getAttachUrl(['callback' => $jsCallback]);\nheader(\"Location: $url\", true, 303);\n"
  },
  {
    "path": "demo/ajax-broker/index.html",
    "content": "<!doctype html>\n<html>\n    <head>\n        <title>Single Sign-On Ajax demo</title>\n        <link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic\">\n        <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css\">\n        <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css\">\n        <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/jquery.growl@1.3.5/stylesheets/jquery.growl.min.css\" />\n\n        <style>\n            .state {\n                display: none;\n            }\n            body.anonymous .state.anonymous,\n            body.authenticated .state.authenticated {\n                display: initial;\n            }\n        </style>\n    </head>\n    <body>\n        <div class=\"container\">\n            <h1>Single Sign-On Ajax demo</h1>\n\n            <form id=\"login-form\" class=\"state anonymous\">\n                <label for=\"inputUsername\">Username</label>\n                <input type=\"text\" name=\"username\" id=\"inputUsername\">\n\n                <label for=\"inputPassword\">Password</label>\n                <input type=\"password\" name=\"password\" id=\"inputPassword\">\n\n                <button type=\"submit\">Login</button>\n            </form>\n\n            <div class=\"state authenticated\">\n                <h3>Logged in</h3>\n                <dl id=\"user-info\"></dl>\n                \n                <button id=\"logout\">Logout</button>\n            </div>\n        </div>\n\n        <script src=\"https://code.jquery.com/jquery-3.5.1.min.js\"></script>\n        <script src=\"https://cdn.jsdelivr.net/npm/jquery.growl@1.3.5/javascripts/jquery.growl.min.js\"></script>\n\n        <script src=\"app.js\"></script>\n    </body>\n</html>\n\n"
  },
  {
    "path": "demo/ajax-broker/verify.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Broker\\Broker;\n\nrequire_once __DIR__ . '/../../vendor/autoload.php';\n\n// Configure the broker.\n$broker = new Broker(\n    getenv('SSO_SERVER'),\n    getenv('SSO_BROKER_ID'),\n    getenv('SSO_BROKER_SECRET')\n);\n\n// Set the verification cookie.\n// Don't do this in JS using document.cookie, because an XSS vulnerability would grand access to the session.\n$broker->verify($_POST['verify']);\n\nhttp_response_code(204);\n"
  },
  {
    "path": "demo/broker/error.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n$brokerId = getenv('SSO_BROKER_ID');\n\n$error = isset($exception) ? $exception->getMessage() : ($_GET['sso_error'] ?? \"Unknown error\");\n$errorDetails = isset($exception) && $exception->getPrevious() !== null\n    ? $exception->getPrevious()->getMessage()\n    : null;\n\n?>\n<!doctype html>\n<html>\n    <head>\n        <title>Single Sign-On demo (<?= $brokerId ?>)</title>\n\n        <link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic\">\n        <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css\">\n        <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css\">\n\n        <style>\n            .error {\n                background: #fff3f3;\n                border-left: 0.3rem solid #d00000;\n                padding: 5px 5px 5px 10px;\n                margin-bottom: 20px;\n            }\n        </style>\n    </head>\n    <body>\n        <div class=\"container\">\n            <h1>Single Sign-On demo <small>(<?= $brokerId ?>)</small></h1>\n\n            <div class=\"error\">\n                <?php if ($errorDetails === null) : ?>\n                    <?= htmlentities($error) ?>\n                <?php else : ?>\n                    <details>\n                        <summary><?= htmlentities($error) ?></summary>\n                        <?= $errorDetails ?>\n                    </details>\n                <?php endif ?>\n            </div>\n            \n            <a href=\"/\">Try again</a>\n        </div>\n    </body>\n</html>\n"
  },
  {
    "path": "demo/broker/include/attach.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Broker\\Broker;\n\nrequire_once __DIR__ . '/functions.php';\n\n// Configure the broker.\n$broker = new Broker(\n    getenv('SSO_SERVER'),\n    getenv('SSO_BROKER_ID'),\n    getenv('SSO_BROKER_SECRET')\n);\n\n// Handle error from SSO server\nif (isset($_GET['sso_error'])) {\n    require __DIR__ . '/../error.php';\n    exit();\n}\n\n// Handle verification from SSO server\nif (isset($_GET['sso_verify'])) {\n    $broker->verify($_GET['sso_verify']);\n\n    $url = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];\n    $redirectUrl = preg_replace('/sso_verify=\\w+&|[?&]sso_verify=\\w+$/', '', $url);\n    redirect($redirectUrl);\n    exit();\n}\n\n// Attach through redirect if the client isn't attached yet.\nif (!$broker->isAttached()) {\n    $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];\n    $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]);\n\n    redirect($attachUrl);\n    exit();\n}\n\nreturn $broker;\n"
  },
  {
    "path": "demo/broker/include/functions.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * Redirect and through specified URL\n */\nfunction redirect(string $url): void\n{\n    header(\"Location: $url\", true, 303);\n    echo \"You're redirected to <a href='$url'>$url</a>\";\n}\n"
  },
  {
    "path": "demo/broker/index.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Broker\\Broker;\n\nrequire_once __DIR__ . '/../../vendor/autoload.php';\n\n/** @var Broker $broker */\n$broker = require_once __DIR__ . '/include/attach.php';\n\n// Get the user info from the SSO server via the API.\ntry {\n    $userInfo = $broker->request('GET', '/api/info.php');\n} catch (\\RuntimeException $exception) {\n    require __DIR__ . '/error.php';\n    exit();\n}\n\n?>\n<!doctype html>\n<html>\n    <head>\n        <title><?= $broker->getBrokerId() ?> &mdash; Single Sign-On demo</title>\n\n        <link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic\">\n        <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css\">\n        <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css\">\n    </head>\n    <body>\n        <div class=\"container\">\n            <h1>Single Sign-On demo <small>(Broker: <?= $broker->getBrokerId() ?>)</small></h1>\n\n            <?php if ($userInfo === null) : ?>\n                <h3>Logged out</h3>\n                <a id=\"login\" class=\"button\" href=\"login.php\">Login</a>\n            <?php else : ?>\n                <h3>Logged in</h3>\n                <pre><?= json_encode($userInfo, JSON_PRETTY_PRINT); ?></pre>\n\n                <a id=\"logout\" class=\"button\" href=\"logout.php\">Logout</a>\n            <?php endif ?>\n        </div>\n    </body>\n</html>\n"
  },
  {
    "path": "demo/broker/login.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Broker\\Broker;\n\nrequire_once __DIR__ . '/../../vendor/autoload.php';\nrequire_once __DIR__ . '/include/functions.php';\n\n/** @var Broker $broker */\n$broker = require_once __DIR__ . '/include/attach.php';\n\n// Handle POST request\nif ($_SERVER['REQUEST_METHOD'] === 'POST') {\n    try {\n        $credentials = [\n            'username' => $_POST['username'],\n            'password' => $_POST['password']\n        ];\n\n        $broker->request('POST', '/api/login.php', $credentials);\n\n        redirect('index.php');\n        exit();\n    } catch (\\RuntimeException $exception) {\n        $error = $exception->getMessage();\n    }\n}\n\n// Show the form in case of GET request\n?>\n<!doctype html>\n<html>\n    <head>\n        <title><?= $broker->getBrokerId() ?> | Login (Single Sign-On demo)</title>\n\n        <link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic\">\n        <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css\">\n        <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css\">\n\n        <style>\n            .error {\n                background: #fff3f3;\n                border-left: 0.3rem solid #d00000;\n                padding: 5px 5px 5px 10px;\n                margin-bottom: 20px;\n            }\n        </style>\n    </head>\n    <body>\n        <div class=\"container\">\n            <h1>Single Sign-On demo <small>(Broker: <?= $broker->getBrokerId() ?>)</small></h1>\n\n            <?php if (isset($error)) : ?>\n                <div class=\"error\"><?= $error ?></div>\n            <?php endif; ?>\n\n            <form action=\"login.php\" method=\"post\">\n                <label for=\"inputUsername\">Username</label>\n                <input type=\"text\" name=\"username\" id=\"inputUsername\">\n\n                <label for=\"inputPassword\">Password</label>\n                <input type=\"password\" name=\"password\" id=\"inputPassword\">\n\n                <button type=\"submit\">Login</button>\n            </form>\n        </div>\n    </body>\n</html>\n"
  },
  {
    "path": "demo/broker/logout.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Broker\\Broker;\n\nrequire_once __DIR__ . '/../../vendor/autoload.php';\n\n/** @var Broker $broker */\n$broker = require_once __DIR__ . '/include/attach.php';\n\ntry {\n    $broker->request('POST', 'api/logout.php');\n} catch (\\RuntimeException $exception) {\n    require __DIR__ . '/error.php';\n    exit();\n}\n\nredirect('index.php');\n"
  },
  {
    "path": "demo/server/api/info.php",
    "content": "<?php\n\n/**\n * API endpoint to get the user info.\n * If you don't have a method to authenticate users, consider [jasny/auth](https://github.com/jasny/auth).\n */\n\ndeclare(strict_types=1);\n\nrequire_once __DIR__ . '/../../../vendor/autoload.php';\n\n// Instantiate the SSO server and start the broker session\nrequire __DIR__ . '/../include/start_broker_session.php';\n\n// No user is logged in; respond with a 204 No content\nif (!isset($_SESSION['user'])) {\n    http_response_code(204);\n    exit();\n}\n\n// Get the username from the session\n$username = $_SESSION['user'];\n\n// Read config with user info\n$config = require __DIR__ . '/../include/config.php';\n\n// Output user info as JSON.\n$info = ['username' => $username] + $config['users'][$username];\nunset($info['password']);\n\nheader('Content-Type: application/json');\necho json_encode($info);\n"
  },
  {
    "path": "demo/server/api/login.php",
    "content": "<?php\n\n/**\n * Endpoint that allows the broker to ask the user for credentials and login via the API.\n *\n * You only need this if you want to allow the broker to login and logout, not when logging in and out should be done\n * via the UI of the server.\n *\n * If you don't have a method to authenticate users, consider [jasny/auth](https://github.com/jasny/auth).\n */\n\ndeclare(strict_types=1);\n\nrequire_once __DIR__ . '/../../../vendor/autoload.php';\n\n// Instantiate the SSO server and start the broker session\nrequire __DIR__ . '/../include/start_broker_session.php';\n\n// Take the username and password from the POST params.\n$username = $_POST['username'];\n$password = $_POST['password'];\n\n// Authenticate the user.\nif (!isset($config['users'][$username]) || !password_verify($password, $config['users'][$username]['password'])) {\n    http_response_code(400);\n    header('Content-Type: application/json');\n    echo json_encode(['error' => \"Invalid credentials\"]);\n    exit();\n}\n\n// Store the current user in the session.\n$_SESSION['user'] = $username;\n\n// Output user info as JSON.\n$info = ['username' => $username] + $config['users'][$username];\nunset($info['password']);\n\nheader('Content-Type: application/json');\necho json_encode($info);\n"
  },
  {
    "path": "demo/server/api/logout.php",
    "content": "<?php\n\n/**\n * Endpoint that allows the broker to logout via the API.\n *\n * You only need this if you want to allow the broker to login and logout, not when logging in and out should be done\n * via the UI of the server.\n *\n * If you don't have a method to authenticate users, consider [jasny/auth](https://github.com/jasny/auth).\n */\n\ndeclare(strict_types=1);\n\nrequire_once __DIR__ . '/../../../vendor/autoload.php';\n\n// Instantiate the SSO server and start the broker session\nrequire __DIR__ . '/../include/start_broker_session.php';\n\n// Clear the session user.\nunset($_SESSION['user']);\n\n// Done (no output)\nhttp_response_code(204);\n"
  },
  {
    "path": "demo/server/attach.php",
    "content": "<?php\n\n/**\n * An example script for attaching the broker token to a user session.\n */\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Server\\Server;\nuse Desarrolla2\\Cache\\File as FileCache;\nuse Jasny\\SSO\\Server\\ExceptionInterface as SSOException;\n\nrequire_once __DIR__ . '/../../vendor/autoload.php';\n\n// Preflight for CORS\nif ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {\n  http_response_code(200);\n  exit;\n}\n\n// Config contains the secret keys of the brokers for this demo.\n$config = require __DIR__ . '/include/config.php';\n\n// Instantiate the SSO server.\n$ssoServer = (new Server(\n    function (string $id) use ($config) {\n        return $config['brokers'][$id] ?? null;  // Callback to get the broker secret. You might fetch this from DB.\n    },\n    new FileCache(sys_get_temp_dir())            // Any PSR-16 compatible cache\n))->withLogger(new Loggy('SSO'));\n\ntry {\n    // Attach the broker token to the user session. Uses query parameters from $_GET.\n    $verificationCode = $ssoServer->attach();\n    $error = null;\n} catch (SSOException $exception) {\n    $verificationCode = null;\n    $error = ['code' => $exception->getCode(), 'message' => $exception->getMessage()];\n}\n\n// The token is attached; output 'success'.\n\n// In this demo we support multiple types of attaching the session. If you choose to support only one method,\n// you don't need to detect the return type.\n\n$returnType =\n    (isset($_GET['return_url']) ? 'redirect' : null) ??\n    (isset($_GET['callback']) ? 'jsonp' : null) ??\n    (strpos($_SERVER['HTTP_ACCEPT'], 'text/html') !== false ? 'html' : null) ??\n    (strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false ? 'json' : null);\n\nswitch ($returnType) {\n    case 'json':\n        header('Access-Control-Allow-Origin: *');\n        header('Content-type: application/json');\n        http_response_code($error['code'] ?? 200);\n        echo json_encode($error ?? ['verify' => $verificationCode]);\n        break;\n\n    case 'jsonp':\n        $callback = $_GET['callback'];\n        if (!preg_match('/^[a-z_]\\w*$/i', $callback)) {\n            http_response_code(400);\n            header('Content-Type: text/plain');\n            echo \"JSONP callback must be a valid js function name\";\n            break;\n        }\n    \n        header('Content-type: application/javascript');\n        $data = json_encode($error ?? ['verify' => $verificationCode]);\n        $responseCode = $error['code'] ?? 200;\n        echo \"{$callback}($data, $responseCode);\";\n        break;\n\n    case 'redirect':\n        $query = isset($error) ? 'sso_error=' . $error['message'] : 'sso_verify=' . $verificationCode;\n        $url = $_GET['return_url'] . (strpos($_GET['return_url'], '?') === false ? '?' : '&') . $query;\n        header('Location: ' . $url, true, 303);\n        echo \"You're being redirected to <a href='{$url}'>$url</a>\";\n        break;\n\n    default:\n        http_response_code(400);\n        header('Content-Type: text/plain');\n        echo \"Missing 'return_url' query parameter\";\n        break;\n}\n"
  },
  {
    "path": "demo/server/include/config.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * Configuration for the demo server.\n * Don't copy this to your own project.\n */\n\nreturn [\n    'brokers' => [\n        'Alice' => [\n            'secret' => '8iwzik1bwd',\n            'domains' => ['localhost'],\n        ],\n        'Greg' => [\n            'secret' => '7pypoox2pc',\n            'domains' => ['localhost'],\n        ],\n        'Julius' => [\n            'secret' => 'ceda63kmhp',\n            'domains' => ['localhost'],\n        ],\n    ],\n    'users' => [\n        'jackie' => [\n            'fullname' => 'Jackie Black',\n            'email' => 'jackie.black@example.com',\n            'password' => '$2y$10$lVUeiphXLAm4pz6l7lF9i.6IelAqRxV4gCBu8GBGhCpaRb6o0qzUO' // jackie123\n        ],\n        'john' => [\n            'fullname' => 'John Doe',\n            'email' => 'john.doe@example.com',\n            'password' => '$2y$10$RU85KDMhbh8pDhpvzL6C5.kD3qWpzXARZBzJ5oJ2mFoW7Ren.apC2' // john123\n        ],\n    ],\n];\n"
  },
  {
    "path": "demo/server/include/start_broker_session.php",
    "content": "<?php\n\n/**\n * Create a new SSO Server instance.\n */\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Server\\Server;\nuse Desarrolla2\\Cache\\File as FileCache;\nuse Jasny\\SSO\\Server\\ExceptionInterface as SsoException;\n\n// Config contains the secret keys of the brokers for this demo.\n$config = require __DIR__ . '/config.php';\n\n// Instantiate the SSO server.\n$ssoServer = (new Server(\n    function (string $id) use ($config) {\n        return $config['brokers'][$id] ?? null;  // Callback to get the broker secret. You might fetch this from DB.\n    },\n    new FileCache(sys_get_temp_dir())            // Any PSR-16 compatible cache\n))->withLogger(new Loggy('SSO'));\n\n// Start the session using the broker bearer token (rather than a session cookie).\ntry {\n    $ssoServer->startBrokerSession();\n} catch (SsoException $exception) {\n    $code = $exception->getCode();\n    $message = $code === 403\n        ? \"Invalid or expired bearer token\"\n        : $exception->getMessage();\n\n    http_response_code($code);\n    if ($code === 401) {\n        header('WWW-Authenticate: Bearer');\n    }\n\n    header('Content-Type: application/json');\n    echo json_encode(['error' => $message]);\n\n    exit();\n}\n\nreturn $ssoServer;\n"
  },
  {
    "path": "phpcs.xml",
    "content": "<?xml version=\"1.0\"?>\n<ruleset name=\"Jasny\">\n    <description>The Jasny coding standard.</description>\n \n    <!-- Include the whole PSR-1 standard -->\n    <rule ref=\"PSR1\"/>\n    <!-- Include the whole PSR-2 standard -->\n    <rule ref=\"PSR2\"/>\n \n    <!-- TODO: Add own rules -->\n</ruleset>\n\n"
  },
  {
    "path": "phpstan.neon",
    "content": "parameters:\n    level: 7\n    paths:\n        - src\n    reportUnmatchedIgnoredErrors: false\nincludes:\n  \t- vendor/phpstan/phpstan-strict-rules/rules.neon\n"
  },
  {
    "path": "src/Broker/Broker.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Broker;\n\nuse Jasny\\Immutable;\n\n/**\n * Single sign-on broker.\n *\n * The broker lives on the website visited by the user. The broken doesn't have any user credentials stored. Instead it\n * will talk to the SSO server in name of the user, verifying credentials and getting user information.\n */\nclass Broker\n{\n    use Immutable\\With;\n\n    /**\n     * URL of SSO server.\n     * @var string\n     */\n    protected $url;\n\n    /**\n     * My identifier, given by SSO provider.\n     * @var string\n     */\n    protected $broker;\n\n    /**\n     * My secret word, given by SSO provider.\n     * @var string\n     */\n    protected $secret;\n\n    /**\n     * @var bool\n     */\n    protected $initialized = false;\n\n    /**\n     * Session token of the client.\n     * @var string|null\n     */\n    protected $token;\n\n    /**\n     * Verification code returned by the server.\n     * @var string|null\n     */\n    protected $verificationCode;\n\n    /**\n     * @var \\ArrayAccess<string,mixed>\n     */\n    protected $state;\n\n    /**\n     * @var Curl\n     */\n    protected $curl;\n\n    /**\n     * Class constructor\n     *\n     * @param string $url     Url of SSO server\n     * @param string $broker  My identifier, given by SSO provider.\n     * @param string $secret  My secret word, given by SSO provider.\n     */\n    public function __construct(string $url, string $broker, string $secret)\n    {\n        if (!(bool)preg_match('~^https?://~', $url)) {\n            throw new \\InvalidArgumentException(\"Invalid SSO server URL '$url'\");\n        }\n\n        if ((bool)preg_match('/\\W/', $broker)) {\n            throw new \\InvalidArgumentException(\"Invalid broker id '$broker': must be alphanumeric\");\n        }\n\n        $this->url = $url;\n        $this->broker = $broker;\n        $this->secret = $secret;\n\n        $this->state = new Cookies();\n    }\n\n    /**\n     * Get a copy with a different handler for the user state (like cookie or session).\n     *\n     * @param \\ArrayAccess<string,mixed> $handler\n     * @return static\n     */\n    public function withTokenIn(\\ArrayAccess $handler): self\n    {\n        return $this->withProperty('state', $handler);\n    }\n\n    /**\n     * Set a custom wrapper for cURL.\n     *\n     * @param Curl $curl\n     * @return static\n     */\n    public function withCurl(Curl $curl): self\n    {\n        return $this->withProperty('curl', $curl);\n    }\n\n    /**\n     * Get Wrapped cURL.\n     */\n    protected function getCurl(): Curl\n    {\n        if (!isset($this->curl)) {\n            $this->curl = new Curl(); // @codeCoverageIgnore\n        }\n\n        return $this->curl;\n    }\n\n    /**\n     * Get the broker identifier.\n     */\n    public function getBrokerId(): string\n    {\n        return $this->broker;\n    }\n\n    /**\n     * Get information from cookie.\n     */\n    protected function initialize(): void\n    {\n        if ($this->initialized) {\n            return;\n        }\n\n        $this->token = $this->state[$this->getCookieName('token')] ?? null;\n        $this->verificationCode = $this->state[$this->getCookieName('verify')] ?? null;\n        $this->initialized = true;\n    }\n\n    /**\n     * @return string|null\n     */\n    protected function getToken(): ?string\n    {\n        $this->initialize();\n\n        return $this->token;\n    }\n\n    /**\n     * @return string|null\n     */\n    protected function getVerificationCode(): ?string\n    {\n        $this->initialize();\n\n        return $this->verificationCode;\n    }\n\n    /**\n     * Get the cookie name.\n     * The broker name is part of the cookie name. This resolves issues when multiple brokers are on the same domain.\n     */\n    protected function getCookieName(string $type): string\n    {\n        $brokerName = preg_replace('/[_\\W]+/', '_', strtolower($this->broker));\n\n        return \"sso_{$type}_{$brokerName}\";\n    }\n\n    /**\n     * Generate session id from session key\n     *\n     * @throws NotAttachedException\n     */\n    public function getBearerToken(): string\n    {\n        $token = $this->getToken();\n        $verificationCode = $this->getVerificationCode();\n\n        if ($verificationCode === null) {\n            throw new NotAttachedException(\"The client isn't attached to the SSO server for this broker. \"\n                . \"Make sure that the '\" . $this->getCookieName('verify') . \"' cookie is set.\");\n        }\n\n        return \"SSO-{$this->broker}-{$token}-\" . $this->generateChecksum(\"bearer:$verificationCode\");\n    }\n\n    /**\n     * Generate session token.\n     */\n    protected function generateToken(): void\n    {\n        $this->token = base_convert(bin2hex(random_bytes(32)), 16, 36);\n        $this->state[$this->getCookieName('token')] = $this->token;\n    }\n\n    /**\n     * Clears session token.\n     */\n    public function clearToken(): void\n    {\n        unset($this->state[$this->getCookieName('token')]);\n        unset($this->state[$this->getCookieName('verify')]);\n\n        $this->token = null;\n        $this->verificationCode = null;\n    }\n\n    /**\n     * Check if we have an SSO token.\n     */\n    public function isAttached(): bool\n    {\n        return $this->getVerificationCode() !== null;\n    }\n\n    /**\n     * Get URL to attach session at SSO server.\n     *\n     * @param array<string,mixed> $params\n     * @return string\n     */\n    public function getAttachUrl(array $params = []): string\n    {\n        if ($this->getToken() === null) {\n            $this->generateToken();\n        }\n\n        $data = [\n            'broker' => $this->broker,\n            'token' => $this->getToken(),\n            'checksum' => $this->generateChecksum('attach')\n        ];\n\n        return $this->url . \"?\" . http_build_query($data + $params);\n    }\n\n    /**\n     * Verify attaching to the SSO server by providing the verification code.\n     */\n    public function verify(string $code): void\n    {\n        $this->initialize();\n\n        if ($this->verificationCode === $code) {\n            return;\n        }\n\n        if ($this->verificationCode !== null) {\n            trigger_error(\"SSO attach already verified\", E_USER_WARNING);\n            return;\n        }\n\n        $this->verificationCode = $code;\n        $this->state[$this->getCookieName('verify')] = $code;\n    }\n\n    /**\n     * Generate checksum for a broker.\n     */\n    protected function generateChecksum(string $command): string\n    {\n        return base_convert(hash_hmac('sha256', $command . ':' . $this->token, $this->secret), 16, 36);\n    }\n\n    /**\n     * Get the request url for a command\n     *\n     * @param string                     $path\n     * @param array<string,mixed>|string $params   Query parameters\n     * @return string\n     */\n    protected function getRequestUrl(string $path, $params = ''): string\n    {\n        $query = is_array($params) ? http_build_query($params) : $params;\n\n        $base = $path[0] === '/'\n            ? preg_replace('~^(\\w+://[^/]+).*~', '$1', $this->url)\n            : preg_replace('~/[^/]*$~', '', $this->url);\n\n        return $base . '/' . ltrim($path, '/') . ($query !== '' ? '?' . $query : '');\n    }\n\n\n    /**\n     * Send an HTTP request to the SSO server.\n     *\n     * @param string                     $method  HTTP method: 'GET', 'POST', 'DELETE'\n     * @param string                     $path    Relative path\n     * @param array<string,mixed>|string $data    Query or post parameters\n     * @return mixed\n     * @throws RequestException\n     */\n    public function request(string $method, string $path, $data = '')\n    {\n        $url = $this->getRequestUrl($path, $method === 'POST' ? '' : $data);\n        $headers = [\n            'Accept: application/json',\n            'Authorization: Bearer ' . $this->getBearerToken()\n        ];\n\n        ['httpCode' => $httpCode, 'contentType' => $contentType, 'body' => $body] =\n            $this->getCurl()->request($method, $url, $headers, $method === 'POST' ? $data : '');\n\n        return $this->handleResponse($httpCode, $contentType, $body);\n    }\n\n    /**\n     * Handle the response of the cURL request.\n     *\n     * @param int    $httpCode  HTTP status code\n     * @param string|null $ctHeader  Content-Type header\n     * @param string $body      Response body\n     * @return mixed\n     * @throws RequestException\n     */\n    protected function handleResponse(int $httpCode, $ctHeader, string $body)\n    {\n        if ($httpCode === 204) {\n            return null;\n        }\n\n        [$contentType] = explode(';', $ctHeader, 2);\n\n        if ($contentType != 'application/json') {\n            throw new RequestException(\n                \"Expected 'application/json' response, got '$contentType'\",\n                500,\n                new RequestException($body, $httpCode)\n            );\n        }\n\n        try {\n            $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);\n        } catch (\\JsonException $exception) {\n            throw new RequestException(\"Invalid JSON response from server\", 500, $exception);\n        }\n\n        if ($httpCode >= 400) {\n            throw new RequestException($data['error'] ?? $body, $httpCode);\n        }\n\n        return $data;\n    }\n}\n"
  },
  {
    "path": "src/Broker/Cookies.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Broker;\n\n/**\n * Use global $_COOKIE and setcookie() to persist the client token.\n *\n * @implements \\ArrayAccess<string,mixed>\n * @codeCoverageIgnore\n */\nclass Cookies implements \\ArrayAccess\n{\n    /** @var int */\n    protected int $ttl;\n\n    /** @var string */\n    protected string $path;\n\n    /** @var string */\n    protected string $domain;\n\n    /** @var bool */\n    protected bool $secure;\n\n    public function __construct(int $ttl = 3600, string $path = '', string $domain = '', bool $secure = false)\n    {\n        $this->ttl = $ttl;\n        $this->path = $path;\n        $this->domain = $domain;\n        $this->secure = $secure;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function offsetExists(mixed $offset): bool\n    {\n        return isset($_COOKIE[$offset]);\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function offsetGet(mixed $offset): mixed\n    {\n        return $_COOKIE[$offset] ?? null;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function offsetSet(mixed $offset, mixed $value): void\n    {\n        $success = setcookie($offset, $value, time() + $this->ttl, $this->path, $this->domain, $this->secure, true);\n\n        if (!$success) {\n            throw new \\RuntimeException(\"Failed to set cookie '$offset'\");\n        }\n\n        $_COOKIE[$offset] = $value;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function offsetUnset(mixed $offset): void\n    {\n        setcookie($offset, '', 1, $this->path, $this->domain, $this->secure, true);\n        unset($_COOKIE[$offset]);\n    }\n}\n"
  },
  {
    "path": "src/Broker/Curl.php",
    "content": "<?php /** @noinspection PhpComposerExtensionStubsInspection */\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Broker;\n\n/**\n * Wrapper for cURL.\n *\n * @codeCoverageIgnore\n */\nclass Curl\n{\n    /**\n     * Curl constructor.\n     *\n     * @throws \\Exception if curl extension isn't loaded\n     */\n    public function __construct()\n    {\n        if (!extension_loaded('curl')) {\n            throw new \\Exception(\"cURL extension not loaded\");\n        }\n    }\n\n    /**\n     * Send an HTTP request to the SSO server.\n     *\n     * @param string                     $method   HTTP method: 'GET', 'POST', 'DELETE'\n     * @param string                     $url      Full URL\n     * @param string[]                   $headers  HTTP headers\n     * @param array<string,mixed>|string $data     Query or post parameters\n     * @return array{httpCode:int,contentType:string,body:string}\n     * @throws RequestException\n     */\n    public function request(string $method, string $url, array $headers, $data = '')\n    {\n        $ch = curl_init($url);\n\n        if ($ch === false) {\n            throw new \\RuntimeException(\"Failed to initialize a cURL session\");\n        }\n\n        if ($data !== [] && $data !== '') {\n            $post = is_string($data) ? $data : http_build_query($data);\n            curl_setopt($ch, CURLOPT_POSTFIELDS, $post);\n        }\n\n        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);\n        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);\n        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);\n\n        $responseBody = (string)curl_exec($ch);\n\n        if (curl_errno($ch) != 0) {\n            throw new RequestException('Server request failed: ' . curl_error($ch));\n        }\n\n        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);\n        $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? 'text/html';\n\n        return ['httpCode' => $httpCode, 'contentType' => $contentType, 'body' => $responseBody];\n    }\n}\n"
  },
  {
    "path": "src/Broker/NotAttachedException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Broker;\n\n/**\n * Exception thrown when a request is done while no session is attached\n */\nclass NotAttachedException extends \\RuntimeException\n{\n}\n"
  },
  {
    "path": "src/Broker/RequestException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Broker;\n\n/**\n * SSO Request failed.\n */\nclass RequestException extends \\RuntimeException\n{\n}\n"
  },
  {
    "path": "src/Broker/Session.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Broker;\n\n/**\n * Use global $_SESSION to persist the client token.\n *\n * @implements \\ArrayAccess<string,mixed>\n * @codeCoverageIgnore\n */\nclass Session implements \\ArrayAccess\n{\n    /**\n     * @inheritDoc\n     */\n    public function offsetSet($name, $value): void\n    {\n        $_SESSION[$name] = $value;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function offsetUnset($name): void\n    {\n        unset($_SESSION[$name]);\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function offsetGet($name)\n    {\n        return $_SESSION[$name] ?? null;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function offsetExists($name): bool\n    {\n        return isset($_SESSION[$name]);\n    }\n}\n"
  },
  {
    "path": "src/Server/BrokerException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Server;\n\n/**\n * Exception that's thrown if request from broker is invalid.\n * Should result in an HTTP 4xx response.\n */\nclass BrokerException extends \\RuntimeException implements ExceptionInterface\n{\n}\n"
  },
  {
    "path": "src/Server/ExceptionInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Server;\n\ninterface ExceptionInterface\n{\n    /**\n     * Gets the Exception message.\n     *\n     * @return string\n     */\n    public function getMessage();\n\n    /**\n     * Gets the Exception code.\n     *\n     * @return int\n     */\n    public function getCode();\n}\n"
  },
  {
    "path": "src/Server/GlobalSession.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Server;\n\n/**\n * Interact with the global session using PHP's session_* functions.\n *\n * @codeCoverageIgnore\n */\nclass GlobalSession implements SessionInterface\n{\n    /**\n     * Options passed to session_start().\n     * @var array<string,mixed>\n     */\n    protected $options;\n\n    /**\n     * Class constructor.\n     *\n     * @param array<string,mixed> $options  Options passed to session_start().\n     */\n    public function __construct(array $options = [])\n    {\n        $this->options = $options + ['cookie_samesite' => 'None', 'cookie_secure' => true];\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function getId(): string\n    {\n        return session_id();\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function start(): void\n    {\n        $started = session_status() !== PHP_SESSION_ACTIVE\n            ? session_start($this->options)\n            : true;\n\n        if (!$started) {\n            $err = error_get_last() ?? ['message' => 'Failed to start session'];\n            throw new ServerException($err['message'], 500);\n        }\n\n        // Session shouldn't be empty when resumed.\n        $_SESSION['_sso_init'] = 1;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function resume(string $id): void\n    {\n        session_id($id);\n        $started = session_start($this->options);\n\n        if (!$started) {\n            $err = error_get_last() ?? ['message' => 'Failed to start session'];\n            throw new ServerException($err['message'], 500);\n        }\n\n        if ($_SESSION === []) {\n            session_abort();\n            throw new BrokerException(\"Session has expired. Client must attach with new token.\", 401);\n        }\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function isActive(): bool\n    {\n        return session_status() === PHP_SESSION_ACTIVE;\n    }\n}\n"
  },
  {
    "path": "src/Server/Server.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Server;\n\nuse Jasny\\Immutable;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Psr\\Log\\NullLogger;\nuse Psr\\SimpleCache\\CacheInterface;\n\n/**\n * Single sign-on server.\n * The SSO server is responsible of managing users sessions which are available for brokers.\n */\nclass Server\n{\n    use Immutable\\With;\n\n    /**\n     * Callback to get the secret for a broker.\n     * @var \\Closure\n     */\n    protected $getBrokerInfo;\n\n    /**\n     * Storage for broker session links.\n     * @var CacheInterface\n     */\n    protected $cache;\n\n    /**\n     * @var LoggerInterface\n     */\n    protected $logger;\n\n    /**\n     * Service to interact with sessions.\n     * @var SessionInterface\n     */\n    protected $session;\n\n    /**\n     * Class constructor.\n     *\n     * @phpstan-param callable(string):?array{secret:string,domains:string[]} $getBrokerInfo\n     * @phpstan-param CacheInterface                                          $cache\n     */\n    public function __construct(callable $getBrokerInfo, CacheInterface $cache)\n    {\n        $this->getBrokerInfo = \\Closure::fromCallable($getBrokerInfo);\n        $this->cache = $cache;\n\n        $this->logger = new NullLogger();\n        $this->session = new GlobalSession();\n    }\n\n    /**\n     * Get a copy of the service with logging.\n     *\n     * @return static\n     */\n    public function withLogger(LoggerInterface $logger): self\n    {\n        return $this->withProperty('logger', $logger);\n    }\n\n    /**\n     * Get a copy of the service with a custom session service.\n     *\n     * @return static\n     */\n    public function withSession(SessionInterface $session): self\n    {\n        return $this->withProperty('session', $session);\n    }\n\n\n    /**\n     * Start the session for broker requests to the SSO server.\n     *\n     * @throws BrokerException\n     * @throws ServerException\n     */\n    public function startBrokerSession(?ServerRequestInterface $request = null): void\n    {\n        if ($this->session->isActive()) {\n            throw new ServerException(\"Session is already started\", 500);\n        }\n\n        $bearer = $this->getBearerToken($request);\n\n        [$brokerId, $token, $checksum] = $this->parseBearer($bearer);\n\n        $sessionId = $this->cache->get($this->getCacheKey($brokerId, $token));\n\n        if ($sessionId === null) {\n            $this->logger->warning(\n                \"Bearer token isn't attached to a client session\",\n                ['broker' => $brokerId, 'token' => $token]\n            );\n            throw new BrokerException(\"Bearer token isn't attached to a client session\", 403);\n        }\n\n        $code = $this->getVerificationCode($brokerId, $token, $sessionId);\n        $this->validateChecksum($checksum, 'bearer', $brokerId, $token, $code);\n\n        $this->session->resume($sessionId);\n\n        $this->logger->debug(\n            \"Broker request with session\",\n            ['broker' => $brokerId, 'token' => $token, 'session' => $sessionId]\n        );\n    }\n\n    /**\n     * Get bearer token from Authorization header.\n     */\n    protected function getBearerToken(?ServerRequestInterface $request = null): string\n    {\n        $authorization = $request === null\n            ? ($_SERVER['HTTP_AUTHORIZATION'] ?? '') // @codeCoverageIgnore\n            : $request->getHeaderLine('Authorization');\n\n        [$type, $token] = explode(' ', $authorization, 2) + ['', ''];\n\n        if ($type !== 'Bearer') {\n            $this->logger->warning(\"Broker didn't use bearer authentication: \"\n                . ($authorization === '' ? \"No 'Authorization' header\" : \"$type authorization used\"));\n            throw new BrokerException(\"Broker didn't use bearer authentication\", 401);\n        }\n\n        return $token;\n    }\n\n    /**\n     * Get the broker id and token from the bearer token used by the broker.\n     *\n     * @return string[]\n     * @throws BrokerException\n     */\n    protected function parseBearer(string $bearer): array\n    {\n        $matches = null;\n\n        if (!(bool)preg_match('/^SSO-(\\w*+)-(\\w*+)-([a-z0-9]*+)$/', $bearer, $matches)) {\n            $this->logger->warning(\"Invalid bearer token\", ['bearer' => $bearer]);\n            throw new BrokerException(\"Invalid bearer token\", 403);\n        }\n\n        return array_slice($matches, 1);\n    }\n\n    /**\n     * Generate cache key for linking the broker token to the client session.\n     */\n    protected function getCacheKey(string $brokerId, string $token): string\n    {\n        return \"SSO-{$brokerId}-{$token}\";\n    }\n\n    /**\n     * Get the broker secret using the configured callback.\n     *\n     * @param string $brokerId\n     * @return string|null\n     */\n    protected function getBrokerSecret(string $brokerId): ?string\n    {\n        return ($this->getBrokerInfo)($brokerId)['secret'] ?? null;\n    }\n\n    /**\n     * Generate the verification code based on the token using the server secret.\n     */\n    protected function getVerificationCode(string $brokerId, string $token, string $sessionId): string\n    {\n        return base_convert(hash('sha256', $brokerId . $token . $sessionId), 16, 36);\n    }\n\n    /**\n     * Generate checksum for a broker.\n     */\n    protected function generateChecksum(string $command, string $brokerId, string $token): string\n    {\n        $secret = $this->getBrokerSecret($brokerId);\n\n        if ($secret === null) {\n            $this->logger->warning(\"Unknown broker\", ['broker' => $brokerId, 'token' => $token]);\n            throw new BrokerException(\"Broker is unknown or disabled\", 403);\n        }\n\n        return base_convert(hash_hmac('sha256', $command . ':' . $token, $secret), 16, 36);\n    }\n\n    /**\n     * Assert that the checksum matches the expected checksum.\n     *\n     * @throws BrokerException\n     */\n    protected function validateChecksum(\n        string $checksum,\n        string $command,\n        string $brokerId,\n        string $token,\n        ?string $code = null\n    ): void {\n        $expected = $this->generateChecksum($command . ($code !== null ? \":$code\" : ''), $brokerId, $token);\n\n        if ($checksum !== $expected) {\n            $this->logger->warning(\n                \"Invalid $command checksum\",\n                ['expected' => $expected, 'received' => $checksum, 'broker' => $brokerId, 'token' => $token]\n                    + ($code !== null ? ['verification_code' => $code] : [])\n            );\n            throw new BrokerException(\"Invalid $command checksum\", 403);\n        }\n    }\n\n    /**\n     * Validate that the URL has a domain that is allowed for the broker.\n     */\n    public function validateDomain(string $type, string $url, string $brokerId, ?string $token = null): void\n    {\n        $domains = ($this->getBrokerInfo)($brokerId)['domains'] ?? [];\n        $host = parse_url($url, PHP_URL_HOST);\n\n        if (!in_array($host, $domains, true)) {\n            $this->logger->warning(\n                \"Domain of $type is not allowed for broker\",\n                [$type => $url, 'broker' => $brokerId] + ($token !== null ? ['token' => $token] : [])\n            );\n            throw new BrokerException(\"Domain of $type is not allowed\", 400);\n        }\n    }\n\n    /**\n     * Attach a client session to a broker session.\n     * Returns the verification code.\n     *\n     * @throws BrokerException\n     * @throws ServerException\n     */\n    public function attach(?ServerRequestInterface $request = null): string\n    {\n        ['broker' => $brokerId, 'token' => $token] = $this->processAttachRequest($request);\n\n        $this->session->start();\n\n        $this->assertNotAttached($brokerId, $token);\n\n        $key = $this->getCacheKey($brokerId, $token);\n        $cached = $this->cache->set($key, $this->session->getId());\n\n        $info = ['broker' => $brokerId, 'token' => $token, 'session' => $this->session->getId()];\n\n        if (!$cached) {\n            $this->logger->error(\"Failed to attach bearer token to session id due to cache issue\", $info);\n            throw new ServerException(\"Failed to attach bearer token to session id\", 500);\n        }\n\n        $this->logger->info(\"Attached broker token to session\", $info);\n\n        return $this->getVerificationCode($brokerId, $token, $this->session->getId());\n    }\n\n    /**\n     * Assert that the token isn't already attached to a different session.\n     */\n    protected function assertNotAttached(string $brokerId, string $token): void\n    {\n        $key = $this->getCacheKey($brokerId, $token);\n        $attached = $this->cache->get($key);\n\n        if ($attached !== null && $attached !== $this->session->getId()) {\n            $this->logger->warning(\"Token is already attached\", [\n                'broker' => $brokerId,\n                'token' => $token,\n                'attached_to' => $attached,\n                'session' => $this->session->getId()\n            ]);\n            throw new BrokerException(\"Token is already attached\", 400);\n        }\n    }\n\n    /**\n     * Validate attach request and return broker id and token.\n     *\n     * @param ServerRequestInterface|null $request\n     * @return array{broker:string,token:string}\n     * @throws BrokerException\n     */\n    protected function processAttachRequest(?ServerRequestInterface $request): array\n    {\n        $brokerId = $this->getRequiredQueryParam($request, 'broker');\n        $token = $this->getRequiredQueryParam($request, 'token');\n        $checksum = $this->getRequiredQueryParam($request, 'checksum');\n\n        $this->validateChecksum($checksum, 'attach', $brokerId, $token);\n\n        $origin = $this->getHeader($request, 'Origin');\n        if ($origin !== '') {\n            $this->validateDomain('origin', $origin, $brokerId, $token);\n        }\n\n        $referer = $this->getHeader($request, 'Referer');\n        if ($referer !== '') {\n            $this->validateDomain('referer', $referer, $brokerId, $token);\n        }\n\n        $returnUrl = $this->getQueryParam($request, 'return_url');\n        if ($returnUrl !== null) {\n            $this->validateDomain('return_url', $returnUrl, $brokerId, $token);\n        }\n\n        return ['broker' => $brokerId, 'token' => $token];\n    }\n\n    /**\n     * Get query parameter from PSR-7 request or $_GET.\n     */\n    protected function getQueryParam(?ServerRequestInterface $request, string $key): ?string\n    {\n        $params = $request === null\n            ? $_GET // @codeCoverageIgnore\n            : $request->getQueryParams();\n\n        return $params[$key] ?? null;\n    }\n\n    /**\n     * Get required query parameter from PSR-7 request or $_GET.\n     *\n     * @throws BrokerException if query parameter isn't set\n     */\n    protected function getRequiredQueryParam(?ServerRequestInterface $request, string $key): string\n    {\n        $value = $this->getQueryParam($request, $key);\n\n        if ($value === null) {\n            throw new BrokerException(\"Missing '$key' query parameter\", 400);\n        }\n\n        return $value;\n    }\n\n    /**\n     * Get HTTP Header from PSR-7 request or $_SERVER.\n     *\n     * @param ServerRequestInterface $request\n     * @param string                 $key\n     * @return string\n     */\n    protected function getHeader(?ServerRequestInterface $request, string $key): string\n    {\n        return $request === null\n            ? ($_SERVER['HTTP_' . str_replace('-', '_', strtoupper($key))] ?? '') // @codeCoverageIgnore\n            : $request->getHeaderLine($key);\n    }\n}\n"
  },
  {
    "path": "src/Server/ServerException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Server;\n\n/**\n * Exception that's thrown if something unexpectedly went wrong on the server.\n * Should result in an HTTP 5xx response.\n */\nclass ServerException extends \\RuntimeException implements ExceptionInterface\n{\n}\n"
  },
  {
    "path": "src/Server/SessionInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Server;\n\n/**\n * Interface to start a session.\n */\ninterface SessionInterface\n{\n    /**\n     * @see session_id()\n     */\n    public function getId(): string;\n\n    /**\n     * Start a new session.\n     * @see session_start()\n     *\n     * @throws ServerException if session can't be started.\n     */\n    public function start(): void;\n\n    /**\n     * Resume an existing session.\n     *\n     * @throws ServerException if session can't be started.\n     * @throws BrokerException if session is expired\n     */\n    public function resume(string $id): void;\n\n    /**\n     * Check if a session is active. (status PHP_SESSION_ACTIVE)\n     * @see session_status()\n     */\n    public function isActive(): bool;\n}\n"
  },
  {
    "path": "tests/_bootstrap.php",
    "content": "<?php\n\ndefine('ROOT_DIR', dirname(__DIR__));\n\nrequire_once ROOT_DIR . '/vendor/autoload.php';\n"
  },
  {
    "path": "tests/_output/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "tests/_support/DemoTester.php",
    "content": "<?php\n\n\n/**\n * Inherited Methods\n * @method void wantToTest($text)\n * @method void wantTo($text)\n * @method void execute($callable)\n * @method void expectTo($prediction)\n * @method void expect($prediction)\n * @method void amGoingTo($argumentation)\n * @method void am($role)\n * @method void lookForwardTo($achieveValue)\n * @method void comment($description)\n * @method void pause()\n *\n * @SuppressWarnings(PHPMD)\n*/\nclass DemoTester extends \\Codeception\\Actor\n{\n    use _generated\\DemoTesterActions;\n\n   /**\n    * Define custom actions here\n    */\n}\n"
  },
  {
    "path": "tests/_support/Helper/Demo.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Helper;\n\nuse Codeception\\Module\\PhpBrowser;\nuse PhpBuiltInServer;\n\n// here you can define custom actions\n// all public methods declared in helper class will be available in $I\n\nclass Demo extends \\Codeception\\Module\n{\n    protected $server;\n    protected $broker1;\n    protected $broker2;\n\n    /**\n     * Hook runs before any test of the suite is run\n     *\n     * @param array $settings\n     */\n    public function _beforeSuite($settings = [])\n    {\n        parent::_beforeSuite($settings);\n\n        $this->server = new PhpBuiltInServer(ROOT_DIR . '/demo/server/', 8200);\n\n        $this->broker1 = new PhpBuiltInServer(\n            ROOT_DIR . '/demo/broker/',\n            8201,\n            [\n                'SSO_SERVER' => 'http://localhost:8200/attach.php',\n                'SSO_BROKER_ID' => 'Alice',\n                'SSO_BROKER_SECRET' => '8iwzik1bwd'\n            ]\n        );\n\n        $this->broker2 = new PhpBuiltInServer(\n            ROOT_DIR . '/demo/broker/',\n            8202,\n            [\n                'SSO_SERVER' => 'http://localhost:8200/attach.php',\n                'SSO_BROKER_ID' => 'Greg',\n                'SSO_BROKER_SECRET' => '7pypoox2pc'\n            ]\n        );\n    }\n\n    /**\n     * Hook runs after all test of the suite is run\n     */\n    public function _afterSuite()\n    {\n        $this->server = null;\n        $this->broker1 = null;\n        $this->broker2 = null;\n\n        parent::_afterSuite();\n    }\n\n    /**\n     * Set URL of broker as base host.\n     *\n     * @param int $nr\n     */\n    public function amOnBroker(int $nr): void\n    {\n        if ($nr < 1 || $nr > 2) {\n            throw new \\Exception(\"Invalid broker number $nr\");\n        }\n\n        $port = $nr + 8200;\n\n        /** @var PhpBrowser $phpBrowser */\n        $phpBrowser =$this->getModule('PhpBrowser');\n\n        $phpBrowser->amOnUrl(\"http://localhost:$port\");\n    }\n}\n"
  },
  {
    "path": "tests/_support/Helper/Unit.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Helper;\n\n// here you can define custom actions\n// all public methods declared in helper class will be available in $I\n\nclass Unit extends \\Codeception\\Module\n{\n\n}\n"
  },
  {
    "path": "tests/_support/PhpBuiltInServer.php",
    "content": "<?php\n\nuse Codeception\\Configuration;\n\n/**\n * Start/stop the PHP built-in web server on localhost\n */\nclass PhpBuiltInServer\n{\n    /**\n     * HTTP port\n     * @var int\n     */\n    protected $port;\n    \n    /**\n     * @var resource\n     */\n    protected $handle;\n\n    /**\n     * @var resource[]\n     */\n    protected $pipes;\n\n    /**\n     * Class constructor\n     *\n     * @param string   $documentRoot  Path to router file.\n     * @param int      $port\n     * @param string[] $env           Environment variables\n     */\n    public function __construct(string $documentRoot, int $port, array $env = [])\n    {\n        $this->port = $port;\n        \n        $this->run($documentRoot, $env);\n        $this->testConnection();\n    }\n\n    /**\n     * Start the web server\n     *\n     * @param string   $documentRoot  Path to router file.\n     * @param string[] $env           Environment variables\n     */\n    protected function run(string $documentRoot, array $env): void\n    {\n        if ($this->handle) {\n            trigger_error(\"Built-in webserver on port {$this->port} already started\", E_USER_NOTICE);\n            return;\n        }\n\n        $cmd = $this->getCommand($documentRoot);\n        $descriptorSpec = [\n            [\"pipe\", \"r\"],\n            ['file', Configuration::logDir() . \"phpbuiltinserver.{$this->port}.output.txt\", 'w'],\n            ['file', Configuration::logDir() . \"phpbuiltinserver.{$this->port}.errors.txt\", 'a']\n        ];\n        $pipes = [];\n\n        $this->handle = proc_open($cmd, $descriptorSpec, $this->pipes, ROOT_DIR, $env, ['bypass_shell' => true]);\n        fclose($this->pipes[0]); // close stdin\n\n        $this->registerShutdown();\n\n        usleep(10000);\n        $status = proc_get_status($this->handle);\n\n        if (!$status['running']) {\n            proc_close($this->handle);\n\n            $error = stream_get_contents($pipes[2]) ?: stream_get_contents($pipes[1]);\n            throw new \\Exception(\"Failed to start PHP built-in web server. $error\");\n        }\n    }\n\n    /**\n     * Get the executable command to start the webserver.\n     */\n    protected function getCommand(string $documentRoot): string\n    {\n        // Platform uses POSIX process handling. Use exec to avoid controlling the shell process instead of the PHP\n        // interpreter.\n        $exec = (PHP_OS !== 'WINNT' && PHP_OS !== 'WIN32') ? 'exec ' : '';\n\n        return $exec . escapeshellcmd(PHP_BINARY)\n            . \" -S localhost:{$this->port}\"\n            . \" -t \" . escapeshellarg($documentRoot)\n            . ($this->isRemoteDebug() ? ' -dxdebug.remote_enable=1' : '');\n    }\n\n    /**\n     * Check if codeception remote debugging is available.\n     */\n    protected function isRemoteDebug(): bool\n    {\n        return Configuration::isExtensionEnabled('Codeception\\Extension\\RemoteDebug');\n    }\n\n    /**\n     * Make sure we can connect to the webserver\n     */\n    protected function testConnection()\n    {\n        for ($i=0; $i < 5; $i++) {\n            if ($this->connect()) {\n                return;\n            }\n            sleep(1);\n        }\n        \n        $err = error_get_last();\n        throw new \\Exception(\"Failed to connect to built-in web server: {$err['message']}\");\n    }\n\n    /**\n     * Connect to the webserver\n     */\n    protected function connect(): bool\n    {\n        $sock = @fsockopen('localhost', $this->port, $errno, $errstr, 1);\n\n        return is_resource($sock) && $errno === 0;\n    }\n    \n    /**\n     * Stop the web server\n     */\n    public function __destruct()\n    {\n        $this->stop();\n    }\n\n    /**\n     * Stop the web server\n     */\n    public function stop(): void\n    {\n        if ($this->handle === null) {\n            return;\n        }\n\n        foreach ($this->pipes as $pipe) {\n            if (is_resource($pipe)) {\n                fclose($pipe);\n            }\n        }\n\n        proc_terminate($this->handle, 15);\n        unset($this->handle);\n    }\n\n    /**\n     * Register shutdown function to stop webserver on an error.\n     */\n    protected function registerShutdown(): void\n    {\n        $handle = $this->handle;\n\n        register_shutdown_function(function () use ($handle) {\n            if (is_resource($handle)) {\n                proc_terminate($handle);\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "tests/_support/UnitTester.php",
    "content": "<?php\n\n\n/**\n * Inherited Methods\n * @method void wantToTest($text)\n * @method void wantTo($text)\n * @method void execute($callable)\n * @method void expectTo($prediction)\n * @method void expect($prediction)\n * @method void amGoingTo($argumentation)\n * @method void am($role)\n * @method void lookForwardTo($achieveValue)\n * @method void comment($description)\n * @method void pause()\n *\n * @SuppressWarnings(PHPMD)\n*/\nclass UnitTester extends \\Codeception\\Actor\n{\n    use _generated\\UnitTesterActions;\n\n   /**\n    * Define custom actions here\n    */\n}\n"
  },
  {
    "path": "tests/demo/DemoCept.php",
    "content": "<?php\n\n/** @var \\Codeception\\Scenario $scenario */\n$I = new DemoTester($scenario);\n$I->wantTo(\"login at broker 1 and see I'm also logged in at broker 2\");\n\n// ---\n$I->amGoingTo(\"login at Alice (broker 1)\");\n\n$I->amOnBroker(1);\n$I->see('Alice');\n$I->see('Logged out');\n\n$I->click('Login');\n$I->seeElement('form', ['action' => 'login.php']);\n$I->submitForm('form', [\n    'username' => 'john',\n    'password' => 'john123'\n]);\n\n$I->see('Logged in');\n$I->see('John Doe');\n$I->see('john.doe@example.com');\n\n// ---\n$I->amGoingTo(\"visit Greg (broker 2)\");\n$I->expect(\"john to be logged in through SSO\");\n\n$I->amOnBroker(2);\n$I->see('Greg');\n\n$I->see('Logged in');\n$I->see('John Doe');\n$I->see('john.doe@example.com');\n\n// ---\n$I->amGoingTo(\"logout at Greg (broker 2)\");\n\n$I->amOnBroker(2);\n$I->see('Greg');\n\n$I->click('Logout');\n$I->see('Logged out');\n\n// ---\n$I->amGoingTo(\"visit Alice (broker 1)\");\n$I->expect(\"john to be logged out through SSO\");\n\n$I->amOnBroker(1);\n$I->see('Alice');\n\n$I->see('Logged out');\n"
  },
  {
    "path": "tests/demo.suite.yml",
    "content": "actor: DemoTester\nmodules:\n    enabled:\n        - \\Helper\\Demo\n        - PhpBrowser:\n              url: 'http://localhost:8201'"
  },
  {
    "path": "tests/unit/Broker/AttachTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\Tests\\SSO\\Broker;\n\nuse Jasny\\PHPUnit\\ExpectWarningTrait;\nuse Jasny\\PHPUnit\\SafeMocksTrait;\nuse Jasny\\SSO\\Broker\\Broker;\nuse Jasny\\SSO\\Broker\\Curl;\nuse Jasny\\SSO\\Broker\\NotAttachedException;\nuse Jasny\\Tests\\SSO\\TokenTrait;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse PHPUnit\\Framework\\TestCase;\n\n/**\n * Test methods for attaching the broker token to a client session.\n *\n * @covers \\Jasny\\SSO\\Broker\\Broker\n */\nclass AttachTest extends TestCase\n{\n    use TokenTrait;\n    use SafeMocksTrait;\n    use ExpectWarningTrait;\n\n    /**\n     * @var \\ArrayObject\n     */\n    protected $session;\n\n    /**\n     * @var Curl&MockObject\n     */\n    protected $curl;\n\n    /**\n     * @var Broker\n     */\n    protected $broker;\n\n    public function setUp(): void\n    {\n        $this->session = new \\ArrayObject();\n        $this->curl = $this->createMock(Curl::class);\n\n        $this->broker = (new Broker('https://example.com/attach', 'foo', 'bar'))\n            ->withTokenIn($this->session)\n            ->withCurl($this->curl);\n    }\n\n    public function testUrlValidationInConstruct()\n    {\n        $this->expectException(\\InvalidArgumentException::class);\n        $this->expectExceptionMessage(\"Invalid SSO server URL 'example'\");\n\n        new Broker('example', 'foo', 'bar');\n    }\n\n    public function testBrokerIdValidationInConstruct()\n    {\n        $this->expectException(\\InvalidArgumentException::class);\n        $this->expectExceptionMessage(\"Invalid broker id 'foo-1': must be alphanumeric\");\n\n        new Broker('https://example.com', 'foo-1', 'bar');\n    }\n\n    public function testGetBrokerId()\n    {\n        $this->assertEquals('foo', $this->broker->getBrokerId());\n    }\n\n    public function testGetAttachUrl()\n    {\n        $url = $this->broker->getAttachUrl();\n\n        $this->assertArrayHasKey('sso_token_foo', $this->session);\n\n        $token = $this->session[\"sso_token_foo\"];\n        $checksum = $this->generateChecksum('attach', 'bar', $token);\n\n        $this->assertEquals(\"https://example.com/attach?broker=foo&token=$token&checksum=$checksum\", $url);\n        $this->assertFalse($this->broker->isAttached());\n    }\n\n    public function testGetAttachUrlWithParams()\n    {\n        $url = $this->broker->getAttachUrl([\n            'return_url' => 'http://broker.example.com/',\n            'color' => 'red',\n        ]);\n\n        $this->assertArrayHasKey('sso_token_foo', $this->session);\n\n        $token = $this->session[\"sso_token_foo\"];\n        $checksum = $this->generateChecksum('attach', 'bar', $token);\n\n        $expectedUrl = \"https://example.com/attach?broker=foo&token=$token&checksum=$checksum&return_url=\"\n            . urlencode('http://broker.example.com/') . '&color=red';\n        $this->assertEquals($expectedUrl, $url);\n    }\n\n    public function testVerify()\n    {\n        $this->session['sso_token_foo'] = '123456';\n\n        $this->assertFalse($this->broker->isAttached());\n\n        $code = $this->getVerificationCode('foo', '123456', 'abc123');\n        $this->broker->verify($code);\n\n        $this->assertArrayHasKey('sso_verify_foo', $this->session);\n        $this->assertEquals($code, $this->session['sso_verify_foo']);\n        $this->assertTrue($this->broker->isAttached());\n    }\n\n    public function testVerifyIsIdempotent()\n    {\n        $code = $this->getVerificationCode('foo', '123456', 'abc123');\n\n        $this->session['sso_token_foo'] = '123456';\n        $this->session['sso_verify_foo'] = $code;\n\n        $this->broker->verify($code);\n\n        $this->assertArrayHasKey('sso_verify_foo', $this->session);\n        $this->assertEquals($code, $this->session['sso_verify_foo']);\n    }\n\n    public function testVerifyIsImmutable()\n    {\n        $this->session['sso_token_foo'] = '123456';\n        $this->session['sso_verify_foo'] = '000000';\n\n        $code = $this->getVerificationCode('foo', '123456', 'abc123');\n\n        $this->expectWarningMessage(\"SSO attach already verified\");\n\n        $this->broker->verify($code);\n\n        $this->assertArrayHasKey('sso_verify_foo', $this->session);\n        $this->assertEquals('000000', $this->session['sso_verify_foo']);\n    }\n\n    public function testClearToken()\n    {\n        $this->session['sso_token_foo'] = '123456';\n        $this->session['sso_verify_foo'] = $this->getVerificationCode('foo', '123456', 'abc123');\n\n        $this->assertTrue($this->broker->isAttached());\n\n        $this->broker->clearToken();\n\n        $this->assertFalse($this->broker->isAttached());\n        $this->assertArrayNotHasKey('sso_token_foo', $this->session);\n        $this->assertArrayNotHasKey('sso_verify_foo', $this->session);\n    }\n}\n"
  },
  {
    "path": "tests/unit/Broker/RequestTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\Tests\\SSO\\Broker;\n\nuse Jasny\\PHPUnit\\ExpectWarningTrait;\nuse Jasny\\PHPUnit\\SafeMocksTrait;\nuse Jasny\\SSO\\Broker\\Broker;\nuse Jasny\\SSO\\Broker\\Curl;\nuse Jasny\\SSO\\Broker\\NotAttachedException;\nuse Jasny\\SSO\\Broker\\RequestException;\nuse Jasny\\Tests\\SSO\\TokenTrait;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse PHPUnit\\Framework\\TestCase;\n\n/**\n * Test broker methods for making API requests to the SSO server.\n *\n * @covers \\Jasny\\SSO\\Broker\\Broker\n */\nclass RequestTest extends TestCase\n{\n    use TokenTrait;\n    use SafeMocksTrait;\n    use ExpectWarningTrait;\n\n    /**\n     * @var \\ArrayObject\n     */\n    protected $session;\n\n    /**\n     * @var Curl&MockObject\n     */\n    protected $curl;\n\n    /**\n     * @var Broker\n     */\n    protected $broker;\n\n    public function setUp(): void\n    {\n        $this->session = new \\ArrayObject([\n            'sso_token_foo' => '123456',\n            'sso_verify_foo' => $this->getVerificationCode('foo', '123456', 'abc123'),\n        ]);\n        $this->curl = $this->createMock(Curl::class);\n\n        $this->broker = (new Broker('https://example.com/attach', 'foo', 'bar'))\n            ->withTokenIn($this->session)\n            ->withCurl($this->curl);\n    }\n\n    public function testGetBearerToken()\n    {\n        $this->assertTrue($this->broker->isAttached());\n\n        $bearer = $this->broker->getBearerToken();\n\n        $this->assertEquals(\n            $this->getBearerToken('foo', 'bar', '123456', 'abc123'),\n            $bearer\n        );\n    }\n\n    public function testGetBearerTokenWhenNotAttached()\n    {\n        unset($this->session['sso_verify_foo']);\n\n        $this->assertFalse($this->broker->isAttached());\n\n        $this->expectException(NotAttachedException::class);\n        $this->expectExceptionMessage(\"The client isn't attached to the SSO server for this broker. \"\n            . \"Make sure that the 'sso_verify_foo' cookie is set.\");\n\n        $this->broker->getBearerToken();\n    }\n\n\n    public function testGetRequest()\n    {\n        $headers = [\n            'Accept: application/json',\n            'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123')\n        ];\n        $this->curl->expects($this->once())->method('request')\n            ->with('GET', 'https://example.com/info', $headers, '')\n            ->willReturn([\n                'httpCode' => 200,\n                'contentType' => 'application/json; charset=utf-8',\n                'body' => '{\"name\": \"John\", \"email\": \"john@example.com\"}',\n            ]);\n\n        $info = $this->broker->request('GET', '/info');\n\n        $this->assertEquals(['name' => 'John', 'email' => 'john@example.com'], $info);\n    }\n\n    public function testPostRequest()\n    {\n        $headers = [\n            'Accept: application/json',\n            'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123')\n        ];\n        $this->curl->expects($this->once())->method('request')\n            ->with('POST', 'https://example.com/user', $headers, ['name' => 'John', 'color' => 'red'])\n            ->willReturn([\n                'httpCode' => 200,\n                'contentType' => 'application/json; charset=utf-8',\n                'body' => '{\"name\": \"John\", \"email\": \"john@example.com\"}',\n            ]);\n\n        $info = $this->broker->request('POST', '/user', ['name' => 'John', 'color' => 'red']);\n\n        $this->assertEquals(['name' => 'John', 'email' => 'john@example.com'], $info);\n    }\n\n    public function testNoContent()\n    {\n        $headers = [\n            'Accept: application/json',\n            'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123')\n        ];\n        $this->curl->expects($this->once())->method('request')\n            ->with('POST', 'https://example.com/go', $headers, '')\n            ->willReturn([\n                'httpCode' => 204,\n                'contentType' => '',\n                'body' => '',\n            ]);\n\n        $info = $this->broker->request('POST', '/go');\n\n        $this->assertNull($info);\n    }\n\n    public function testBadRequest()\n    {\n        $headers = [\n            'Accept: application/json',\n            'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123')\n        ];\n        $this->curl->expects($this->once())->method('request')\n            ->with('GET', 'https://example.com/', $headers, '')\n            ->willReturn([\n                'httpCode' => 400,\n                'contentType' => 'application/json',\n                'body' => '{\"error\": \"something is wrong\"}',\n            ]);\n\n        $this->expectException(RequestException::class);\n        $this->expectExceptionMessage(\"something is wrong\");\n\n        $this->broker->request('GET', '/');\n    }\n\n    public function testInvalidContentType()\n    {\n        $headers = [\n            'Accept: application/json',\n            'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123')\n        ];\n        $this->curl->expects($this->once())->method('request')\n            ->with('GET', 'https://example.com/', $headers, '')\n            ->willReturn([\n                'httpCode' => 200,\n                'contentType' => 'text/html',\n                'body' => '<h1>Foo</h1>',\n            ]);\n\n        $this->expectException(RequestException::class);\n        $this->expectExceptionMessage(\"Expected 'application/json' response, got 'text/html'\");\n\n        $this->broker->request('GET', '/');\n    }\n\n    public function testInvalidJson()\n    {\n        $headers = [\n            'Accept: application/json',\n            'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123')\n        ];\n        $this->curl->expects($this->once())->method('request')\n            ->with('GET', 'https://example.com/', $headers, '')\n            ->willReturn([\n                'httpCode' => 200,\n                'contentType' => 'application/json',\n                'body' => 'not json',\n            ]);\n\n        $this->expectException(RequestException::class);\n        $this->expectExceptionMessage(\"Invalid JSON response from server\");\n\n        $this->broker->request('GET', '/');\n    }\n}\n"
  },
  {
    "path": "tests/unit/Server/AttachTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\Tests\\SSO\\Server;\n\nuse Jasny\\HttpMessage\\ServerRequest;\nuse Jasny\\HttpMessage\\Uri;\nuse Jasny\\PHPUnit\\CallbackMockTrait;\nuse Jasny\\PHPUnit\\SafeMocksTrait;\nuse Jasny\\SSO\\Server\\BrokerException;\nuse Jasny\\SSO\\Server\\Server;\nuse Jasny\\SSO\\Server\\ServerException;\nuse Jasny\\SSO\\Server\\SessionInterface;\nuse Jasny\\Tests\\SSO\\TokenTrait;\nuse Psr\\Log\\LoggerInterface;\nuse Psr\\SimpleCache\\CacheInterface;\nuse function Jasny\\array_without;\n\n/**\n * Test Server::attach() and related methods.\n *\n * @covers \\Jasny\\SSO\\Server\\Server\n */\nclass AttachTest extends \\Codeception\\Test\\Unit\n{\n    use TokenTrait;\n    use CallbackMockTrait;\n    use SafeMocksTrait;\n\n    public function testSuccessfulAttach()\n    {\n        $callback = $this->createCallbackMock(\n            $this->atLeastOnce(),\n            ['foo'],\n            ['secret' => 'bar', 'domains' => ['broker.example.com']]\n        );\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withQueryParams([\n                'broker' => 'foo',\n                'checksum' => $this->generateChecksum('attach', 'bar', '123456'),\n                'token' => '123456',\n                'return_url' => 'https://broker.example.com/attached'\n            ])\n            ->withHeader('Referer', 'https://broker.example.com/login')\n            ->withHeader('Origin', 'https://broker.example.com/');\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $session->expects($this->once())->method('start')->id('start');\n        $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123');\n\n        $cache->expects($this->once())->method('get')\n            ->with('SSO-foo-123456')\n            ->willReturn(null);\n        $cache->expects($this->once())->method('set')\n            ->with('SSO-foo-123456', 'abc123')\n            ->willReturn(true);\n\n        $logger->expects($this->once())->method('info')\n            ->with(\n                \"Attached broker token to session\",\n                ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123']\n            );\n\n        $code = $server->attach($request);\n\n        $this->assertEquals(\n            $this->getVerificationCode('foo', '123456', 'abc123'),\n            $code\n        );\n    }\n\n    public function missingQueryParameterProvider()\n    {\n        return [\n            'broker' => ['broker'],\n            'checksum' => ['checksum'],\n            'token' => ['token'],\n        ];\n    }\n\n    /**\n     * @dataProvider missingQueryParameterProvider\n     */\n    public function testMissingQueryParameter(string $key)\n    {\n        $callback = $this->createCallbackMock($this->never());\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $queryParams = [\n            'broker' => 'foo',\n            'checksum' => $this->generateChecksum('attach', 'bar', '123456'),\n            'token' => '123456',\n            'return_url' => 'https://return_url.example.com/'\n        ];\n\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withQueryParams(array_without($queryParams, [$key]))\n            ->withHeader('Referer', 'https://referer.example.com/')\n            ->withHeader('Origin', 'https://origin.example.com/');\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $session->expects($this->never())->method('start');\n\n        $cache->expects($this->never())->method('get');\n        $cache->expects($this->never())->method('set');\n\n        $this->expectException(BrokerException::class);\n        $this->expectExceptionMessage(\"Missing '$key' query parameter\");\n\n        $server->attach($request);\n    }\n\n    public function domainProvider()\n    {\n        return [\n            'return_url' => ['return_url', ['origin.example.com', 'referer.example.com']],\n            'origin' => ['origin', ['referer.example.com', 'return_url.example.com']],\n            'referer' => ['referer', ['origin.example.com', 'return_url.example.com']],\n        ];\n    }\n\n    /**\n     * @dataProvider domainProvider\n     */\n    public function testInvalidDomain(string $type, array $domains)\n    {\n        $callback = $this->createCallbackMock(\n            $this->atLeastOnce(),\n            ['foo'],\n            ['secret' => 'bar', 'domains' => $domains]\n        );\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withQueryParams([\n                'broker' => 'foo',\n                'checksum' => $this->generateChecksum('attach', 'bar', '123456'),\n                'token' => '123456',\n                'return_url' => 'https://return_url.example.com/'\n            ])\n            ->withHeader('Referer', 'https://referer.example.com/')\n            ->withHeader('Origin', 'https://origin.example.com/');\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $session->expects($this->never())->method('start');\n\n        $cache->expects($this->never())->method('get');\n        $cache->expects($this->never())->method('set');\n\n        $logger->expects($this->once())->method('warning')\n            ->with(\n                \"Domain of $type is not allowed for broker\",\n                [$type => \"https://$type.example.com/\", 'broker' => 'foo', 'token' => '123456']\n            );\n\n        $this->expectException(BrokerException::class);\n        $this->expectExceptionMessage(\"Domain of $type is not allowed\");\n\n        $server->attach($request);\n    }\n\n    public function testInvalidChecksum()\n    {\n        $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']);\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withQueryParams([\n                'broker' => 'foo',\n                'checksum' => '0000000000',\n                'token' => '123456'\n            ]);\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $cache->expects($this->never())->method('get');\n        $cache->expects($this->never())->method('set');\n\n        $checksum = $this->generateChecksum('attach', 'bar', '123456');\n        $logger->expects($this->once())->method('warning')\n            ->with(\n                \"Invalid attach checksum\",\n                ['expected' => $checksum, 'received' => '0000000000', 'broker' => 'foo', 'token' => '123456']\n            );\n\n        $this->expectException(BrokerException::class);\n        $this->expectExceptionMessage(\"Invalid attach checksum\");\n\n        $server->attach($request);\n    }\n\n    public function testUnknownBroker()\n    {\n        $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], null);\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withQueryParams([\n                'broker' => 'foo',\n                'checksum' => $this->generateChecksum('attach', 'bar', '123456'),\n                'token' => '123456'\n            ]);\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $cache->expects($this->never())->method('get');\n        $cache->expects($this->never())->method('set');\n\n        $logger->expects($this->once())->method('warning')\n            ->with(\"Unknown broker\", ['broker' => 'foo', 'token' => '123456']);\n\n        $this->expectException(BrokerException::class);\n        $this->expectExceptionMessage(\"Broker is unknown or disabled\");\n\n        $server->attach($request);\n    }\n\n    public function testAlreadyAttached()\n    {\n        $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']);\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withQueryParams([\n                'broker' => 'foo',\n                'checksum' => $this->generateChecksum('attach', 'bar', '123456'),\n                'token' => '123456'\n            ]);\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $session->expects($this->once())->method('start')->id('start');\n        $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123');\n\n        $cache->expects($this->once())->method('get')\n            ->with('SSO-foo-123456')\n            ->willReturn('xyz543');\n        $cache->expects($this->never())->method('set');\n\n        $logger->expects($this->once())->method('warning')\n            ->with(\n                \"Token is already attached\",\n                ['broker' => 'foo', 'token' => '123456', 'attached_to' => 'xyz543', 'session' => 'abc123']\n            );\n\n        $this->expectException(BrokerException::class);\n        $this->expectExceptionMessage(\"Token is already attached\");\n\n        $server->attach($request);\n    }\n\n    public function testAttachIsIdempotent()\n    {\n        $callback = $this->createCallbackMock(\n            $this->atLeastOnce(),\n            ['foo'],\n            ['secret' => 'bar', 'domains' => ['broker.example.com']]\n        );\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withQueryParams([\n                'broker' => 'foo',\n                'checksum' => $this->generateChecksum('attach', 'bar', '123456'),\n                'token' => '123456',\n                'return_url' => 'https://broker.example.com/attached'\n            ])\n            ->withHeader('Referer', 'https://broker.example.com/login')\n            ->withHeader('Origin', 'https://broker.example.com/');\n\n        $session = $this->createMock(SessionInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session);\n\n        $session->expects($this->once())->method('start')->id('start');\n        $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123');\n\n        $cache->expects($this->once())->method('get')\n            ->with('SSO-foo-123456')\n            ->willReturn('abc123');\n        $cache->expects($this->once())->method('set')\n            ->with('SSO-foo-123456', 'abc123')\n            ->willReturn(true);\n\n        $code = $server->attach($request);\n\n        $this->assertEquals(\n            $this->getVerificationCode('foo', '123456', 'abc123'),\n            $code\n        );\n    }\n\n    public function testCacheIssue()\n    {\n        $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']);\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withQueryParams([\n                'broker' => 'foo',\n                'checksum' => $this->generateChecksum('attach', 'bar', '123456'),\n                'token' => '123456'\n            ]);\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $session->expects($this->once())->method('start')->id('start');\n        $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123');\n\n        $cache->expects($this->once())->method('get')\n            ->with('SSO-foo-123456')\n            ->willReturn(null);\n        $cache->expects($this->once())->method('set')\n            ->with('SSO-foo-123456', 'abc123')\n            ->willReturn(false);\n\n        $logger->expects($this->once())->method('error')\n            ->with(\n                \"Failed to attach bearer token to session id due to cache issue\",\n                ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123']\n            );\n\n        $this->expectException(ServerException::class);\n        $this->expectExceptionMessage(\"Failed to attach bearer token to session id\");\n\n        $server->attach($request);\n    }\n}\n"
  },
  {
    "path": "tests/unit/Server/BrokerSessionTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\Tests\\SSO\\Server;\n\nuse Jasny\\HttpMessage\\ServerRequest;\nuse Jasny\\HttpMessage\\Uri;\nuse Jasny\\PHPUnit\\CallbackMockTrait;\nuse Jasny\\PHPUnit\\SafeMocksTrait;\nuse Jasny\\SSO\\Server\\BrokerException;\nuse Jasny\\SSO\\Server\\Server;\nuse Jasny\\SSO\\Server\\ServerException;\nuse Jasny\\SSO\\Server\\SessionInterface;\nuse Jasny\\Tests\\SSO\\TokenTrait;\nuse Psr\\Log\\LoggerInterface;\nuse Psr\\SimpleCache\\CacheInterface;\n\n/**\n * Test Server::startBrokerSession() and related methods.\n *\n * @covers \\Jasny\\SSO\\Server\\Server\n */\nclass BrokerSessionTest extends \\Codeception\\Test\\Unit\n{\n    use TokenTrait;\n    use CallbackMockTrait;\n    use SafeMocksTrait;\n\n    public function testSuccessfulStart()\n    {\n        $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']);\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123');\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withHeader('Authorization', \"Bearer $bearer\");\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $cache->expects($this->once())->method('get')\n            ->with('SSO-foo-123456')\n            ->willReturn('abc123');\n\n        $session->expects($this->once())->method('isActive')->willReturn(false);\n        $session->expects($this->once())->method('resume')->with('abc123');\n\n        $logger->expects($this->once())->method('debug')\n            ->with(\n                \"Broker request with session\",\n                ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123']\n            );\n\n        $server->startBrokerSession($request);\n    }\n\n    public function testSessionAlreadyStarted()\n    {\n        $callback = $this->createCallbackMock($this->never());\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123');\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withHeader('Authorization', \"Bearer $bearer\");\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $session->expects($this->once())->method('isActive')->willReturn(true);\n        $session->expects($this->never())->method('start');\n\n        $this->expectException(ServerException::class);\n        $this->expectExceptionMessage(\"Session is already started\");\n\n        $server->startBrokerSession($request);\n    }\n\n    public function testMissingAuthorizationHeader()\n    {\n        $callback = $this->createCallbackMock($this->never());\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"));\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $session->expects($this->once())->method('isActive')->willReturn(false);\n        $session->expects($this->never())->method('start');\n\n        $logger->expects($this->once())->method('warning')\n            ->with(\"Broker didn't use bearer authentication: No 'Authorization' header\");\n\n        $this->expectException(BrokerException::class);\n        $this->expectExceptionMessage(\"Broker didn't use bearer authentication\");\n\n        $server->startBrokerSession($request);\n    }\n\n    public function testNoBearerAuthorization()\n    {\n        $callback = $this->createCallbackMock($this->never());\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withHeader(\"Authorization\", \"Basic dXNlcjpwYXNz\");\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $session->expects($this->once())->method('isActive')->willReturn(false);\n        $session->expects($this->never())->method('start');\n\n        $logger->expects($this->once())->method('warning')\n            ->with(\"Broker didn't use bearer authentication: Basic authorization used\");\n\n        $this->expectException(BrokerException::class);\n        $this->expectExceptionMessage(\"Broker didn't use bearer authentication\");\n\n        $server->startBrokerSession($request);\n    }\n\n    public function testInvalidBearerToken()\n    {\n        $callback = $this->createCallbackMock($this->never());\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withHeader('Authorization', \"Bearer 000000\");\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $session->expects($this->once())->method('isActive')->willReturn(false);\n        $session->expects($this->never())->method('start');\n        $session->expects($this->never())->method('resume');\n\n        $logger->expects($this->once())->method('warning')\n            ->with(\"Invalid bearer token\", ['bearer' => '000000']);\n\n        $this->expectException(BrokerException::class);\n        $this->expectExceptionCode(403);\n        $this->expectExceptionMessage(\"Invalid bearer token\");\n\n        $server->startBrokerSession($request);\n    }\n\n    public function testInvalidChecksum()\n    {\n        $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']);\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withHeader('Authorization', \"Bearer SSO-foo-123456-000000\");\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $cache->expects($this->once())->method('get')\n            ->with('SSO-foo-123456')\n            ->willReturn('abc123');\n\n        $session->expects($this->once())->method('isActive')->willReturn(false);\n        $session->expects($this->never())->method('start');\n\n        $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123');\n\n        $logger->expects($this->once())->method('warning')\n            ->with(\n                \"Invalid bearer checksum\",\n                [\n                    'expected' => str_replace('SSO-foo-123456-', '', $bearer),\n                    'received' => '000000',\n                    'broker' => 'foo',\n                    'token' => '123456',\n                    'verification_code' => $this->getVerificationCode('foo', '123456', 'abc123')\n                ]\n            );\n\n        $this->expectException(BrokerException::class);\n        $this->expectExceptionCode(403);\n        $this->expectExceptionMessage(\"Invalid bearer checksum\");\n\n        $server->startBrokerSession($request);\n    }\n\n    public function testUnattachedToken()\n    {\n        $callback = $this->createCallbackMock($this->any(), ['foo'], ['secret' => 'bar']);\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123');\n\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withHeader('Authorization', \"Bearer $bearer\");\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $cache->expects($this->once())->method('get')\n            ->with('SSO-foo-123456')\n            ->willReturn(null);\n\n        $session->expects($this->once())->method('isActive')->willReturn(false);\n        $session->expects($this->never())->method('start');\n\n        $logger->expects($this->once())->method('warning')\n            ->with(\n                \"Bearer token isn't attached to a client session\",\n                ['broker' => 'foo', 'token' => '123456']\n            );\n\n        $this->expectException(BrokerException::class);\n        $this->expectExceptionCode(403);\n        $this->expectExceptionMessage(\"Bearer token isn't attached to a client session\");\n\n        $server->startBrokerSession($request);\n    }\n\n    public function testUnknownBroker()\n    {\n        $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], null);\n\n        $cache = $this->createMock(CacheInterface::class);\n\n        $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123');\n\n        $request = (new ServerRequest())\n            ->withUri(new Uri(\"https://server.example.com/attach.php\"))\n            ->withHeader('Authorization', \"Bearer $bearer\");\n\n        $session = $this->createMock(SessionInterface::class);\n        $logger = $this->createMock(LoggerInterface::class);\n\n        $server = (new Server($callback, $cache))\n            ->withSession($session)\n            ->withLogger($logger);\n\n        $cache->expects($this->once())->method('get')\n            ->with('SSO-foo-123456')\n            ->willReturn('abc123');\n\n        $session->expects($this->once())->method('isActive')->willReturn(false);\n        $session->expects($this->never())->method('start');\n\n        $logger->expects($this->once())->method('warning')\n            ->with(\"Unknown broker\", ['broker' => 'foo', 'token' => '123456']);\n\n        $this->expectException(BrokerException::class);\n        $this->expectExceptionCode(403);\n        $this->expectExceptionMessage(\"Broker is unknown or disabled\");\n\n        $server->startBrokerSession($request);\n    }\n}\n"
  },
  {
    "path": "tests/unit/TokenTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\Tests\\SSO;\n\n/**\n * Traits for server tests.\n */\ntrait TokenTrait\n{\n    protected function generateChecksum(string $command, string $secret, string $token): string\n    {\n        return base_convert(hash_hmac('sha256', $command . ':' . $token, $secret), 16, 36);\n    }\n\n    protected function getVerificationCode(string $brokerId, string $token, string $sessionId): string\n    {\n        return base_convert(hash('sha256', $brokerId . $token . $sessionId), 16, 36);\n    }\n\n    protected function getBearerToken(string $broker, string $secret, string $token, string $sessionId): string\n    {\n        $code = $this->getVerificationCode($broker, $token, $sessionId);\n\n        return \"SSO-{$broker}-{$token}-\" . $this->generateChecksum(\"bearer:$code\", $secret, $token);\n    }\n}\n"
  },
  {
    "path": "tests/unit.suite.yml",
    "content": "actor: UnitTester\nmodules:\n    enabled:\n        - \\Helper\\Unit\ncoverage:\n    enabled: true\n    include:\n        - src/*"
  }
]