Full Code of legalthings/sso for AI

master e806f50c387d cached
54 files
107.8 KB
28.4k tokens
138 symbols
1 requests
Download .txt
Repository: legalthings/sso
Branch: master
Commit: e806f50c387d
Files: 54
Total size: 107.8 KB

Directory structure:
gitextract_z0pcsndb/

├── .gitattributes
├── .github/
│   └── workflows/
│       └── php.yml
├── .gitignore
├── .scrutinizer.yml
├── LICENSE
├── README.md
├── codeception.yml
├── composer.json
├── demo/
│   ├── ajax-broker/
│   │   ├── api.php
│   │   ├── app.js
│   │   ├── attach.php
│   │   ├── index.html
│   │   └── verify.php
│   ├── broker/
│   │   ├── error.php
│   │   ├── include/
│   │   │   ├── attach.php
│   │   │   └── functions.php
│   │   ├── index.php
│   │   ├── login.php
│   │   └── logout.php
│   └── server/
│       ├── api/
│       │   ├── info.php
│       │   ├── login.php
│       │   └── logout.php
│       ├── attach.php
│       └── include/
│           ├── config.php
│           └── start_broker_session.php
├── phpcs.xml
├── phpstan.neon
├── src/
│   ├── Broker/
│   │   ├── Broker.php
│   │   ├── Cookies.php
│   │   ├── Curl.php
│   │   ├── NotAttachedException.php
│   │   ├── RequestException.php
│   │   └── Session.php
│   └── Server/
│       ├── BrokerException.php
│       ├── ExceptionInterface.php
│       ├── GlobalSession.php
│       ├── Server.php
│       ├── ServerException.php
│       └── SessionInterface.php
└── tests/
    ├── _bootstrap.php
    ├── _output/
    │   └── .gitignore
    ├── _support/
    │   ├── DemoTester.php
    │   ├── Helper/
    │   │   ├── Demo.php
    │   │   └── Unit.php
    │   ├── PhpBuiltInServer.php
    │   └── UnitTester.php
    ├── demo/
    │   └── DemoCept.php
    ├── demo.suite.yml
    ├── unit/
    │   ├── Broker/
    │   │   ├── AttachTest.php
    │   │   └── RequestTest.php
    │   ├── Server/
    │   │   ├── AttachTest.php
    │   │   └── BrokerSessionTest.php
    │   └── TokenTrait.php
    └── unit.suite.yml

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitattributes
================================================
/demo export-ignore
/tests export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/.scrutinizer.yml export-ignore
/.travis.yml export-ignore
/phpunit.xml.dist export-ignore
/phpcs.xml.dist export-ignore
/phpstan.neon export-ignore
/README.md export-ignore


================================================
FILE: .github/workflows/php.yml
================================================
name: PHP

on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

jobs:
  run:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        include:
          - php: 8.0
          - php: 8.1
          - php: 8.2
            coverage: '--coverage --coverage-xml'
    name: PHP ${{ matrix.php }}

    steps:
    - uses: actions/checkout@v2
      with:
        fetch-depth: 10
 
    - name: Setup PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: ${{ matrix.php }}
        coverage: xdebug

    - name: Validate composer.json
      run: composer validate

    - name: Install dependencies
      run: composer update --prefer-dist --no-progress --no-suggest

    - name: Run Codeception
      run: vendor/bin/codecept run ${{ matrix.coverage }}

    - name: Upload coverage to Scrutinizer
      if: ${{ matrix.coverage }}
      uses: sudo-bot/action-scrutinizer@latest
      with:
        cli-args: "--format=php-clover build/logs/clover.xml --revision=${{ github.event.pull_request.head.sha || github.sha }}"



================================================
FILE: .gitignore
================================================
.DS_Store
nbproject
/vendor
composer.lock

tests/_output/*
# Elastic Beanstalk Files
.elasticbeanstalk/*
!.elasticbeanstalk/*.cfg.yml
!.elasticbeanstalk/*.global.yml
/tests/_support/_generated/
.idea


================================================
FILE: .scrutinizer.yml
================================================
#language: php
checks:
  php: true
filter:
  excluded_paths:
    - tests
build:
  nodes:
    analysis:
      environment:
        php: 8.2
        postgresql: false
        redis: false
        mongodb: false
      tests:
        override:
            - phpcs-run src
            -
                command: vendor/bin/phpstan analyze --error-format=checkstyle | sed '/^\s*$/d' > phpstan-checkstyle.xml
                analysis:
                    file: phpstan-checkstyle.xml
                    format: 'general-checkstyle'
            - php-scrutinizer-run



================================================
FILE: LICENSE
================================================
Copyright (c) 2020 Arnold Daniels

Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.


================================================
FILE: README.md
================================================
![jasny-banner](https://user-images.githubusercontent.com/100821/62123924-4c501c80-b2c9-11e9-9677-2ebc21d9b713.png)

Single Sign-On for PHP
========

[![PHP](https://github.com/jasny/sso/workflows/PHP/badge.svg)](https://github.com/jasny/sso/actions)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/jasny/sso/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/jasny/sso/?branch=master)
[![Code Coverage](https://scrutinizer-ci.com/g/jasny/sso/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/jasny/sso/?branch=master)
[![Packagist Stable Version](https://img.shields.io/packagist/v/jasny/sso.svg)](https://packagist.org/packages/jasny/sso)
[![Packagist License](https://img.shields.io/packagist/l/jasny/sso.svg)](https://packagist.org/packages/jasny/sso)

Jasny SSO is a relatively simple and straightforward solution for single sign on (SSO).

With SSO, logging into a single website will authenticate you for all affiliate sites. The sites don't need to share a
toplevel domain.

### How it works

When using SSO, we can distinguish 3 parties:

* Client - This is the browser of the visitor
* Broker - The website which is visited
* Server - The place that holds the user info and credentials

The broker has an id and a secret. These are known to both the broker and server.

When the client visits the broker, it creates a random token, which is stored in a cookie. The broker will then send
the client to the server, passing along the broker's id and token. The server creates a hash using the broker id, broker
secret and the token. This hash is used to create a link to the user's session. When the link is created the server
redirects the client back to the broker.

The broker can create the same link hash using the token (from the cookie), the broker id and the broker secret. When
doing requests, it passes that hash as a session id.

The server will notice that the session id is a link and use the linked session. As such, the broker and client are
using the same session. When another broker joins in, it will also use the same session.

For a more in depth explanation, please [read this article](https://github.com/jasny/sso/wiki).

### How is this different from OAuth?

With OAuth, you can authenticate a user at an external server and get access to their profile info. However, you
aren't sharing a session.

A user logs in to website foo.com using Google OAuth. Next they visit website bar.org which also uses Google OAuth.
Regardless of that, they are still required to press the 'login' button on bar.org.

With Jasny SSO both websites use the same session. So when the user visits bar.org, they are automatically logged in.
When they log out (on either of the sites), they are logged out for both.

## Installation

Install this library through composer

    composer require jasny/sso

## Demo

There is a demo server and two demo brokers as example. One with normal redirects and one using
[JSONP](https://en.wikipedia.org/wiki/JSONP) / AJAX.

To prove it's working you should setup the server and two or more brokers, each on their own machine and their own
(sub)domain. However, you can also run both server and brokers on your own machine, simply to test it out.

On *nix (Linux / Unix / OSX) run:

    php -S localhost:8000 -t demo/server/
    export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Alice SSO_BROKER_SECRET=8iwzik1bwd; php -S localhost:8001 -t demo/broker/
    export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Greg SSO_BROKER_SECRET=7pypoox2pc; php -S localhost:8002 -t demo/broker/
    export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Julius SSO_BROKER_SECRET=ceda63kmhp; php -S localhost:8003 -t demo/ajax-broker/

Now open some tabs and visit 

  * http://localhost:8001
  * http://localhost:8002
  * http://localhost:8003

username | password
-------- | --------
jackie   | jackie123
john     | john123

_Note that after logging in, you need to refresh on the other brokers to see the effect._

# Usage

## Server

The `Server` class takes a callback as first constructor argument. This callback should look up the secret
for a broker based on the id.

The second argument must be a PSR-16 compatible cache object. It's used to store the link between broker token and
client session.

```php
use Jasny\SSO\Server\Server;

$brokers = [
    'foo' => ['secret' => '8OyRi6Ix1x', 'domains' => ['example.com']],
    // ...
];

$server = new Server(
    fn($id) => $brokers[$id] ?? null, // Unique secret and allowed domains for each broker.
    new Cache()                       // Any PSR-16 compatible cache
);
```

_In this example the brokers are simply configured as an array, but typically you want to fetch the broker info from a DB._

### Attach

A client needs to attach the broker token to the session id by doing an HTTP request to the server. This request can be
handled by calling `attach()`.

The `attach()` method returns a verification code. This code must be returned to the broker, as it's needed to
calculate the checksum.

```php
$verificationCode = $server->attach();
```

If it's not possible to attach (for instance in case of an incorrect checksum), an Exception is thrown.

### Handle broker API request

After the client session is attached to the broker token, the broker is able to send API requests on behalf of the
client. Calling the `startBrokerSession()` method with start the session of the client based on the bearer token. This
means that these request the server can access the session information of the client through `$_SESSION`.

```
$server->startBrokerSession();
```

The broker could use this to login, logout, get user information, etc. The API for handling such requests is outside
the scope of the project. However since the broker uses normal sessions, any existing the authentication can be used.

_If you're lookup for an authentication library, consider using [Jasny Auth](https://github.com/jasny/auth)._

### PSR-7

By default, the library works with superglobals like `$_GET` and `$_SERVER`. Alternatively it can use a PSR-7 server
request. This can be passed to `attach()` and `startBrokerSession()` as argument.

```php
$verificationCode = $server->attach($serverRequest);
```

### Session interface

By default, the library uses the superglobal `$_SESSION` and the `php_session_*()` functions. It does this through
the `GlobalSession` object, which implements `SessionInterface`.

For projects that use alternative sessions, it's possible to create a wrapper that implements `SessionInterface`.

```php
use Jasny\SSO\Server\SessionInterface;

class CustomerSessionHandler implements SessionInterface
{
    // ...
}
```

The `withSession()` methods creates a copy of the Server object with the custom session interface.

```php
$server = (new Server($callback, $cache))
    ->withSession(new CustomerSessionHandler());
```

The `withSession()` method can also be used with a mock object for testing.

### Logging

Enable logging for debugging and catching issues.

```php
$server = (new Server($callback, $cache))
    ->withLogging(new Logger());
``` 

Any PSR-3 compatible logger can be used, like [Monolog](https://packagist.org/packages/monolog/monolog) or
[Loggy](https://packagist.org/packages/yubb/loggy). The `context` may contain the broker id, token, and session id.

## Broker

When creating a `Broker` instance, you need to pass the server url, broker id and broker secret. The broker id and
secret needs to match the secret registered at the server.

**CAVEAT**: *The broker id MUST be alphanumeric.*

### Attach

Before the broker can do API requests on the client's behalf, the client needs to attach the broker token to the client
session. For this, the client must do an HTTP request to the SSO Server.

The `getAttachUrl()` method will generate a broker token for the client and use it to create an attach URL. The method
takes an array of query parameters as single argument.

There are several methods in making the client do an HTTP request. The broker can redirect the client or do a request
via the browser using AJAX or loading an image.

```php
use Jasny\SSO\Broker\Broker;

// Configure the broker.
$broker = new Broker(
    getenv('SSO_SERVER'),
    getenv('SSO_BROKER_ID'),
    getenv('SSO_BROKER_SECRET')
);

// Attach through redirect if the client isn't attached yet.
if (!$broker->isAttached()) {
    $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
    $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]);

    header("Location: $attachUrl", true, 303);
    echo "You're redirected to <a href='$attachUrl'>$attachUrl</a>";
    exit();
}
```

### Verify

Upon verification the SSO Server will return a verification code (as a query parameter or in the JSON response). The code
is used to calculate the checksum. The verification code prevents session hijacking using an attach link.

```php
if (isset($_GET['sso_verify'])) {
    $broker->verify($_GET['sso_verify']);
}
```

### API requests

Once attached, the broker is able to do API requests on behalf of the client. This can be done by

- using the broker `request()` method, or by
- using any HTTP client like Guzzle

#### Broker request

```php
// Post to modify the user info
$broker->request('POST', '/login', $credentials);

// Get user info
$user = $broker->request('GET', '/user');
```

The `request()` method uses Curl to send HTTP requests, adding the bearer token for authentication. It expects a JSON
response and will automatically decode it.

#### HTTP library (Guzzle)

To use a library like [Guzzle](http://docs.guzzlephp.org/) or [Httplug](http://httplug.io/), get the bearer token using
`getBearerToken()` and set the `Authorization` header
    
```php
$guzzle = new GuzzleHttp\Client(['base_uri' => 'https://sso-server.example.com']);

$res = $guzzle->request('GET', '/user', [
    'headers' => [
        'Authorization' => 'Bearer ' . $broker->getBearerToken()
    ]
]);
```

### Client state

By default, the Broker uses the cookies (`$_COOKIE` and `setcookie()`) via the `Cookies` class to persist the client's
SSO token.

#### Cookie

Instantiate a new `Cookies` object with custom parameters to modify things like cookie TTL, domain and https only.

```php
use Jasny\SSO\Broker\{Broker,Cookies};

$broker = (new Broker(getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET')))
    ->withTokenIn(new Cookies(7200, '/myapp', 'example.com', true));
```

_(The cookie can never be accessed by the browser.)_

#### Session

Alternatively, you can store the SSO token in a PHP session for the broker by using `Session`.

```php
use Jasny\SSO\Broker\{Broker,Session};

session_start();

$broker = (new Broker(getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET')))
    ->withTokenIn(new Session());
```

#### Custom

The method accepts any object that implements `ArrayAccess`, allowing you to create a custom handler if needed.

```php
class CustomStateHandler implements \ArrayAccess
{
    // ...
}
```

This can also be used with a mock object for testing. 


================================================
FILE: codeception.yml
================================================
actor: Tester
paths:
    tests: tests
    log: tests/_output
    data: tests/_data
    support: tests/_support
    envs: tests/_envs
bootstrap: _bootstrap.php
settings:
    colors: true
    memory_limit: 1024M
extensions:
    enabled:
        - Codeception\Extension\RunFailed


================================================
FILE: composer.json
================================================
{
    "name": "jasny/sso",
    "description": "Simple Single Sign-On",
    "keywords": ["sso", "auth"],
    "license": "MIT",
    "homepage": "https://github.com/jasny/sso/wiki",
    "authors": [
        {
            "name": "Arnold Daniels",
            "email": "arnold@jasny.net",
            "homepage": "http://www.jasny.net"
        }
    ],
    "support": {
        "issues": "https://github.com/jasny/sso/issues",
        "source": "https://github.com/jasny/sso"
    },
    "require": {
        "php": "^8.0",
        "ext-json": "*",
        "jasny/immutable": "^2.1",
        "psr/simple-cache": "*",
        "psr/log": "*"
    },
    "require-dev": {
        "phpstan/phpstan": "^0.12.59",
        "codeception/codeception": "^4.1",
        "codeception/module-phpbrowser": "^1.0",
        "codeception/module-rest": "^1.2",
        "desarrolla2/cache": "^3.0",
        "jasny/http-message": "^1.3",
        "jasny/php-code-quality": "^2.6.0",
        "jasny/phpunit-extension": "^0.3.2",
        "yubb/loggy": "^2.1"
    },
    "autoload": {
        "psr-4": {
            "Jasny\\SSO\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Jasny\\Tests\\SSO\\": "tests/unit/"
        }
    },
    "scripts": {
        "test": [
            "phpstan analyse",
            "codecept run",
            "phpcs -p src"
        ]
    },
    "config": {
        "preferred-install": "dist",
        "sort-packages": true,
        "optimize-autoloader": true
    },
    "minimum-stability": "dev",
    "prefer-stable": true
}


================================================
FILE: demo/ajax-broker/api.php
================================================
<?php

declare(strict_types=1);

use Jasny\SSO\Broker\Broker;

require_once __DIR__ . '/../../vendor/autoload.php';

// Configure the broker.
$broker = new Broker(
    getenv('SSO_SERVER'),
    getenv('SSO_BROKER_ID'),
    getenv('SSO_BROKER_SECRET')
);

try {
    $path = '/api/' . $_GET['command'] . '.php';
    $result = $broker->request($_SERVER['REQUEST_METHOD'], $path, $_POST);
} catch (Exception $e) {
    $status = $e->getCode() ?: 500;
    $result = ['error' => $e->getMessage()];
}

// REST
if (!$result) {
    http_response_code(204);
} else {
    http_response_code(isset($status) ? $status : 200);
    header("Content-Type: application/json");
    echo json_encode($result);
}


================================================
FILE: demo/ajax-broker/app.js
================================================
+function ($) {
    // Init
    attach();

    /**
     * Attach session.
     * Will redirect to SSO server.
     */
    function attach()
    {
        const req = $.ajax({
            url: 'attach.php',
            crossDomain: true,
            dataType: 'jsonp'
        });

        req.done(function (data, code) {
            if (code && code >= 400) { // jsonp failure
                showError(data);
                return;
            }

            $.ajax({method: 'POST', url: 'verify.php', data: data}).done(function () {
                doApiRequest('info', null, showUserInfo);
            });
        });

        req.fail(function (jqxhr) {
            showError(jqxhr.responseJSON || jqxhr.textResponse)
        });
    }

    /**
     * Do an AJAX request to the API
     *
     * @param command   API command
     * @param params    POST data
     * @param callback  Callback function
     */
    function doApiRequest(command, params, callback)
    {
        const req = $.ajax({
            url: 'api.php?command=' + command,
            method: params ? 'POST' : 'GET',
            data: params,
            dataType: 'json'
        });

        req.done(callback);

        req.fail(function (jqxhr) {
            showError(jqxhr.responseJSON || jqxhr.textResponse);
        });
    }

    /**
     * Display the error message
     *
     * @param data
     */
    function showError(data)
    {
        const message = typeof data === 'object' && data.error ? data.error : 'Unexpected error';
        $.growl.error({message: message});
    }

    /**
     * Display user info
     *
     * @param info
     */
    function showUserInfo(info)
    {
        const body = $('body');
        const userInfo = $('#user-info');

        body.removeClass('anonymous authenticated');
        userInfo.html('');

        if (info) {
            for (const key in info) {
                userInfo.append($('<dt>').text(key));
                userInfo.append($('<dd>').text(info[key]));
            }
        }

        body.addClass(info ? 'authenticated' : 'anonymous');
    }

    /**
     * Submit login form through AJAX
     */
    $('#login-form').on('submit', function (e) {
        e.preventDefault();

        $('#error').text('').hide();

        var data = {
            username: this.username.value,
            password: this.password.value
        };

        doApiRequest('login', data, showUserInfo);
    });

    $('#logout').on('click', function () {
        doApiRequest('logout', {}, () => showUserInfo(null));
    });
}(jQuery);


================================================
FILE: demo/ajax-broker/attach.php
================================================
<?php

declare(strict_types=1);

use Jasny\SSO\Broker\Broker;

require_once __DIR__ . '/../../vendor/autoload.php';

// Configure the broker.
$broker = new Broker(
    getenv('SSO_SERVER'),
    getenv('SSO_BROKER_ID'),
    getenv('SSO_BROKER_SECRET')
);

$jsCallback = $_GET['callback'];

// Already attached
if ($broker->isAttached()) {
    echo $jsCallback . '(null, 200)';
}

// Attach through redirect if the client isn't attached yet.
$url = $broker->getAttachUrl(['callback' => $jsCallback]);
header("Location: $url", true, 303);


================================================
FILE: demo/ajax-broker/index.html
================================================
<!doctype html>
<html>
    <head>
        <title>Single Sign-On Ajax demo</title>
        <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css">
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css">
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jquery.growl@1.3.5/stylesheets/jquery.growl.min.css" />

        <style>
            .state {
                display: none;
            }
            body.anonymous .state.anonymous,
            body.authenticated .state.authenticated {
                display: initial;
            }
        </style>
    </head>
    <body>
        <div class="container">
            <h1>Single Sign-On Ajax demo</h1>

            <form id="login-form" class="state anonymous">
                <label for="inputUsername">Username</label>
                <input type="text" name="username" id="inputUsername">

                <label for="inputPassword">Password</label>
                <input type="password" name="password" id="inputPassword">

                <button type="submit">Login</button>
            </form>

            <div class="state authenticated">
                <h3>Logged in</h3>
                <dl id="user-info"></dl>
                
                <button id="logout">Logout</button>
            </div>
        </div>

        <script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/jquery.growl@1.3.5/javascripts/jquery.growl.min.js"></script>

        <script src="app.js"></script>
    </body>
</html>



================================================
FILE: demo/ajax-broker/verify.php
================================================
<?php

declare(strict_types=1);

use Jasny\SSO\Broker\Broker;

require_once __DIR__ . '/../../vendor/autoload.php';

// Configure the broker.
$broker = new Broker(
    getenv('SSO_SERVER'),
    getenv('SSO_BROKER_ID'),
    getenv('SSO_BROKER_SECRET')
);

// Set the verification cookie.
// Don't do this in JS using document.cookie, because an XSS vulnerability would grand access to the session.
$broker->verify($_POST['verify']);

http_response_code(204);


================================================
FILE: demo/broker/error.php
================================================
<?php

declare(strict_types=1);

$brokerId = getenv('SSO_BROKER_ID');

$error = isset($exception) ? $exception->getMessage() : ($_GET['sso_error'] ?? "Unknown error");
$errorDetails = isset($exception) && $exception->getPrevious() !== null
    ? $exception->getPrevious()->getMessage()
    : null;

?>
<!doctype html>
<html>
    <head>
        <title>Single Sign-On demo (<?= $brokerId ?>)</title>

        <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css">
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css">

        <style>
            .error {
                background: #fff3f3;
                border-left: 0.3rem solid #d00000;
                padding: 5px 5px 5px 10px;
                margin-bottom: 20px;
            }
        </style>
    </head>
    <body>
        <div class="container">
            <h1>Single Sign-On demo <small>(<?= $brokerId ?>)</small></h1>

            <div class="error">
                <?php if ($errorDetails === null) : ?>
                    <?= htmlentities($error) ?>
                <?php else : ?>
                    <details>
                        <summary><?= htmlentities($error) ?></summary>
                        <?= $errorDetails ?>
                    </details>
                <?php endif ?>
            </div>
            
            <a href="/">Try again</a>
        </div>
    </body>
</html>


================================================
FILE: demo/broker/include/attach.php
================================================
<?php

declare(strict_types=1);

use Jasny\SSO\Broker\Broker;

require_once __DIR__ . '/functions.php';

// Configure the broker.
$broker = new Broker(
    getenv('SSO_SERVER'),
    getenv('SSO_BROKER_ID'),
    getenv('SSO_BROKER_SECRET')
);

// Handle error from SSO server
if (isset($_GET['sso_error'])) {
    require __DIR__ . '/../error.php';
    exit();
}

// Handle verification from SSO server
if (isset($_GET['sso_verify'])) {
    $broker->verify($_GET['sso_verify']);

    $url = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
    $redirectUrl = preg_replace('/sso_verify=\w+&|[?&]sso_verify=\w+$/', '', $url);
    redirect($redirectUrl);
    exit();
}

// Attach through redirect if the client isn't attached yet.
if (!$broker->isAttached()) {
    $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
    $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]);

    redirect($attachUrl);
    exit();
}

return $broker;


================================================
FILE: demo/broker/include/functions.php
================================================
<?php

declare(strict_types=1);

/**
 * Redirect and through specified URL
 */
function redirect(string $url): void
{
    header("Location: $url", true, 303);
    echo "You're redirected to <a href='$url'>$url</a>";
}


================================================
FILE: demo/broker/index.php
================================================
<?php

declare(strict_types=1);

use Jasny\SSO\Broker\Broker;

require_once __DIR__ . '/../../vendor/autoload.php';

/** @var Broker $broker */
$broker = require_once __DIR__ . '/include/attach.php';

// Get the user info from the SSO server via the API.
try {
    $userInfo = $broker->request('GET', '/api/info.php');
} catch (\RuntimeException $exception) {
    require __DIR__ . '/error.php';
    exit();
}

?>
<!doctype html>
<html>
    <head>
        <title><?= $broker->getBrokerId() ?> &mdash; Single Sign-On demo</title>

        <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css">
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css">
    </head>
    <body>
        <div class="container">
            <h1>Single Sign-On demo <small>(Broker: <?= $broker->getBrokerId() ?>)</small></h1>

            <?php if ($userInfo === null) : ?>
                <h3>Logged out</h3>
                <a id="login" class="button" href="login.php">Login</a>
            <?php else : ?>
                <h3>Logged in</h3>
                <pre><?= json_encode($userInfo, JSON_PRETTY_PRINT); ?></pre>

                <a id="logout" class="button" href="logout.php">Logout</a>
            <?php endif ?>
        </div>
    </body>
</html>


================================================
FILE: demo/broker/login.php
================================================
<?php

declare(strict_types=1);

use Jasny\SSO\Broker\Broker;

require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/include/functions.php';

/** @var Broker $broker */
$broker = require_once __DIR__ . '/include/attach.php';

// Handle POST request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    try {
        $credentials = [
            'username' => $_POST['username'],
            'password' => $_POST['password']
        ];

        $broker->request('POST', '/api/login.php', $credentials);

        redirect('index.php');
        exit();
    } catch (\RuntimeException $exception) {
        $error = $exception->getMessage();
    }
}

// Show the form in case of GET request
?>
<!doctype html>
<html>
    <head>
        <title><?= $broker->getBrokerId() ?> | Login (Single Sign-On demo)</title>

        <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css">
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css">

        <style>
            .error {
                background: #fff3f3;
                border-left: 0.3rem solid #d00000;
                padding: 5px 5px 5px 10px;
                margin-bottom: 20px;
            }
        </style>
    </head>
    <body>
        <div class="container">
            <h1>Single Sign-On demo <small>(Broker: <?= $broker->getBrokerId() ?>)</small></h1>

            <?php if (isset($error)) : ?>
                <div class="error"><?= $error ?></div>
            <?php endif; ?>

            <form action="login.php" method="post">
                <label for="inputUsername">Username</label>
                <input type="text" name="username" id="inputUsername">

                <label for="inputPassword">Password</label>
                <input type="password" name="password" id="inputPassword">

                <button type="submit">Login</button>
            </form>
        </div>
    </body>
</html>


================================================
FILE: demo/broker/logout.php
================================================
<?php

declare(strict_types=1);

use Jasny\SSO\Broker\Broker;

require_once __DIR__ . '/../../vendor/autoload.php';

/** @var Broker $broker */
$broker = require_once __DIR__ . '/include/attach.php';

try {
    $broker->request('POST', 'api/logout.php');
} catch (\RuntimeException $exception) {
    require __DIR__ . '/error.php';
    exit();
}

redirect('index.php');


================================================
FILE: demo/server/api/info.php
================================================
<?php

/**
 * API endpoint to get the user info.
 * If you don't have a method to authenticate users, consider [jasny/auth](https://github.com/jasny/auth).
 */

declare(strict_types=1);

require_once __DIR__ . '/../../../vendor/autoload.php';

// Instantiate the SSO server and start the broker session
require __DIR__ . '/../include/start_broker_session.php';

// No user is logged in; respond with a 204 No content
if (!isset($_SESSION['user'])) {
    http_response_code(204);
    exit();
}

// Get the username from the session
$username = $_SESSION['user'];

// Read config with user info
$config = require __DIR__ . '/../include/config.php';

// Output user info as JSON.
$info = ['username' => $username] + $config['users'][$username];
unset($info['password']);

header('Content-Type: application/json');
echo json_encode($info);


================================================
FILE: demo/server/api/login.php
================================================
<?php

/**
 * Endpoint that allows the broker to ask the user for credentials and login via the API.
 *
 * You only need this if you want to allow the broker to login and logout, not when logging in and out should be done
 * via the UI of the server.
 *
 * If you don't have a method to authenticate users, consider [jasny/auth](https://github.com/jasny/auth).
 */

declare(strict_types=1);

require_once __DIR__ . '/../../../vendor/autoload.php';

// Instantiate the SSO server and start the broker session
require __DIR__ . '/../include/start_broker_session.php';

// Take the username and password from the POST params.
$username = $_POST['username'];
$password = $_POST['password'];

// Authenticate the user.
if (!isset($config['users'][$username]) || !password_verify($password, $config['users'][$username]['password'])) {
    http_response_code(400);
    header('Content-Type: application/json');
    echo json_encode(['error' => "Invalid credentials"]);
    exit();
}

// Store the current user in the session.
$_SESSION['user'] = $username;

// Output user info as JSON.
$info = ['username' => $username] + $config['users'][$username];
unset($info['password']);

header('Content-Type: application/json');
echo json_encode($info);


================================================
FILE: demo/server/api/logout.php
================================================
<?php

/**
 * Endpoint that allows the broker to logout via the API.
 *
 * You only need this if you want to allow the broker to login and logout, not when logging in and out should be done
 * via the UI of the server.
 *
 * If you don't have a method to authenticate users, consider [jasny/auth](https://github.com/jasny/auth).
 */

declare(strict_types=1);

require_once __DIR__ . '/../../../vendor/autoload.php';

// Instantiate the SSO server and start the broker session
require __DIR__ . '/../include/start_broker_session.php';

// Clear the session user.
unset($_SESSION['user']);

// Done (no output)
http_response_code(204);


================================================
FILE: demo/server/attach.php
================================================
<?php

/**
 * An example script for attaching the broker token to a user session.
 */

declare(strict_types=1);

use Jasny\SSO\Server\Server;
use Desarrolla2\Cache\File as FileCache;
use Jasny\SSO\Server\ExceptionInterface as SSOException;

require_once __DIR__ . '/../../vendor/autoload.php';

// Preflight for CORS
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
  http_response_code(200);
  exit;
}

// Config contains the secret keys of the brokers for this demo.
$config = require __DIR__ . '/include/config.php';

// Instantiate the SSO server.
$ssoServer = (new Server(
    function (string $id) use ($config) {
        return $config['brokers'][$id] ?? null;  // Callback to get the broker secret. You might fetch this from DB.
    },
    new FileCache(sys_get_temp_dir())            // Any PSR-16 compatible cache
))->withLogger(new Loggy('SSO'));

try {
    // Attach the broker token to the user session. Uses query parameters from $_GET.
    $verificationCode = $ssoServer->attach();
    $error = null;
} catch (SSOException $exception) {
    $verificationCode = null;
    $error = ['code' => $exception->getCode(), 'message' => $exception->getMessage()];
}

// The token is attached; output 'success'.

// In this demo we support multiple types of attaching the session. If you choose to support only one method,
// you don't need to detect the return type.

$returnType =
    (isset($_GET['return_url']) ? 'redirect' : null) ??
    (isset($_GET['callback']) ? 'jsonp' : null) ??
    (strpos($_SERVER['HTTP_ACCEPT'], 'text/html') !== false ? 'html' : null) ??
    (strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false ? 'json' : null);

switch ($returnType) {
    case 'json':
        header('Access-Control-Allow-Origin: *');
        header('Content-type: application/json');
        http_response_code($error['code'] ?? 200);
        echo json_encode($error ?? ['verify' => $verificationCode]);
        break;

    case 'jsonp':
        $callback = $_GET['callback'];
        if (!preg_match('/^[a-z_]\w*$/i', $callback)) {
            http_response_code(400);
            header('Content-Type: text/plain');
            echo "JSONP callback must be a valid js function name";
            break;
        }
    
        header('Content-type: application/javascript');
        $data = json_encode($error ?? ['verify' => $verificationCode]);
        $responseCode = $error['code'] ?? 200;
        echo "{$callback}($data, $responseCode);";
        break;

    case 'redirect':
        $query = isset($error) ? 'sso_error=' . $error['message'] : 'sso_verify=' . $verificationCode;
        $url = $_GET['return_url'] . (strpos($_GET['return_url'], '?') === false ? '?' : '&') . $query;
        header('Location: ' . $url, true, 303);
        echo "You're being redirected to <a href='{$url}'>$url</a>";
        break;

    default:
        http_response_code(400);
        header('Content-Type: text/plain');
        echo "Missing 'return_url' query parameter";
        break;
}


================================================
FILE: demo/server/include/config.php
================================================
<?php

declare(strict_types=1);

/**
 * Configuration for the demo server.
 * Don't copy this to your own project.
 */

return [
    'brokers' => [
        'Alice' => [
            'secret' => '8iwzik1bwd',
            'domains' => ['localhost'],
        ],
        'Greg' => [
            'secret' => '7pypoox2pc',
            'domains' => ['localhost'],
        ],
        'Julius' => [
            'secret' => 'ceda63kmhp',
            'domains' => ['localhost'],
        ],
    ],
    'users' => [
        'jackie' => [
            'fullname' => 'Jackie Black',
            'email' => 'jackie.black@example.com',
            'password' => '$2y$10$lVUeiphXLAm4pz6l7lF9i.6IelAqRxV4gCBu8GBGhCpaRb6o0qzUO' // jackie123
        ],
        'john' => [
            'fullname' => 'John Doe',
            'email' => 'john.doe@example.com',
            'password' => '$2y$10$RU85KDMhbh8pDhpvzL6C5.kD3qWpzXARZBzJ5oJ2mFoW7Ren.apC2' // john123
        ],
    ],
];


================================================
FILE: demo/server/include/start_broker_session.php
================================================
<?php

/**
 * Create a new SSO Server instance.
 */

declare(strict_types=1);

use Jasny\SSO\Server\Server;
use Desarrolla2\Cache\File as FileCache;
use Jasny\SSO\Server\ExceptionInterface as SsoException;

// Config contains the secret keys of the brokers for this demo.
$config = require __DIR__ . '/config.php';

// Instantiate the SSO server.
$ssoServer = (new Server(
    function (string $id) use ($config) {
        return $config['brokers'][$id] ?? null;  // Callback to get the broker secret. You might fetch this from DB.
    },
    new FileCache(sys_get_temp_dir())            // Any PSR-16 compatible cache
))->withLogger(new Loggy('SSO'));

// Start the session using the broker bearer token (rather than a session cookie).
try {
    $ssoServer->startBrokerSession();
} catch (SsoException $exception) {
    $code = $exception->getCode();
    $message = $code === 403
        ? "Invalid or expired bearer token"
        : $exception->getMessage();

    http_response_code($code);
    if ($code === 401) {
        header('WWW-Authenticate: Bearer');
    }

    header('Content-Type: application/json');
    echo json_encode(['error' => $message]);

    exit();
}

return $ssoServer;


================================================
FILE: phpcs.xml
================================================
<?xml version="1.0"?>
<ruleset name="Jasny">
    <description>The Jasny coding standard.</description>
 
    <!-- Include the whole PSR-1 standard -->
    <rule ref="PSR1"/>
    <!-- Include the whole PSR-2 standard -->
    <rule ref="PSR2"/>
 
    <!-- TODO: Add own rules -->
</ruleset>



================================================
FILE: phpstan.neon
================================================
parameters:
    level: 7
    paths:
        - src
    reportUnmatchedIgnoredErrors: false
includes:
  	- vendor/phpstan/phpstan-strict-rules/rules.neon


================================================
FILE: src/Broker/Broker.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\SSO\Broker;

use Jasny\Immutable;

/**
 * Single sign-on broker.
 *
 * The broker lives on the website visited by the user. The broken doesn't have any user credentials stored. Instead it
 * will talk to the SSO server in name of the user, verifying credentials and getting user information.
 */
class Broker
{
    use Immutable\With;

    /**
     * URL of SSO server.
     * @var string
     */
    protected $url;

    /**
     * My identifier, given by SSO provider.
     * @var string
     */
    protected $broker;

    /**
     * My secret word, given by SSO provider.
     * @var string
     */
    protected $secret;

    /**
     * @var bool
     */
    protected $initialized = false;

    /**
     * Session token of the client.
     * @var string|null
     */
    protected $token;

    /**
     * Verification code returned by the server.
     * @var string|null
     */
    protected $verificationCode;

    /**
     * @var \ArrayAccess<string,mixed>
     */
    protected $state;

    /**
     * @var Curl
     */
    protected $curl;

    /**
     * Class constructor
     *
     * @param string $url     Url of SSO server
     * @param string $broker  My identifier, given by SSO provider.
     * @param string $secret  My secret word, given by SSO provider.
     */
    public function __construct(string $url, string $broker, string $secret)
    {
        if (!(bool)preg_match('~^https?://~', $url)) {
            throw new \InvalidArgumentException("Invalid SSO server URL '$url'");
        }

        if ((bool)preg_match('/\W/', $broker)) {
            throw new \InvalidArgumentException("Invalid broker id '$broker': must be alphanumeric");
        }

        $this->url = $url;
        $this->broker = $broker;
        $this->secret = $secret;

        $this->state = new Cookies();
    }

    /**
     * Get a copy with a different handler for the user state (like cookie or session).
     *
     * @param \ArrayAccess<string,mixed> $handler
     * @return static
     */
    public function withTokenIn(\ArrayAccess $handler): self
    {
        return $this->withProperty('state', $handler);
    }

    /**
     * Set a custom wrapper for cURL.
     *
     * @param Curl $curl
     * @return static
     */
    public function withCurl(Curl $curl): self
    {
        return $this->withProperty('curl', $curl);
    }

    /**
     * Get Wrapped cURL.
     */
    protected function getCurl(): Curl
    {
        if (!isset($this->curl)) {
            $this->curl = new Curl(); // @codeCoverageIgnore
        }

        return $this->curl;
    }

    /**
     * Get the broker identifier.
     */
    public function getBrokerId(): string
    {
        return $this->broker;
    }

    /**
     * Get information from cookie.
     */
    protected function initialize(): void
    {
        if ($this->initialized) {
            return;
        }

        $this->token = $this->state[$this->getCookieName('token')] ?? null;
        $this->verificationCode = $this->state[$this->getCookieName('verify')] ?? null;
        $this->initialized = true;
    }

    /**
     * @return string|null
     */
    protected function getToken(): ?string
    {
        $this->initialize();

        return $this->token;
    }

    /**
     * @return string|null
     */
    protected function getVerificationCode(): ?string
    {
        $this->initialize();

        return $this->verificationCode;
    }

    /**
     * Get the cookie name.
     * The broker name is part of the cookie name. This resolves issues when multiple brokers are on the same domain.
     */
    protected function getCookieName(string $type): string
    {
        $brokerName = preg_replace('/[_\W]+/', '_', strtolower($this->broker));

        return "sso_{$type}_{$brokerName}";
    }

    /**
     * Generate session id from session key
     *
     * @throws NotAttachedException
     */
    public function getBearerToken(): string
    {
        $token = $this->getToken();
        $verificationCode = $this->getVerificationCode();

        if ($verificationCode === null) {
            throw new NotAttachedException("The client isn't attached to the SSO server for this broker. "
                . "Make sure that the '" . $this->getCookieName('verify') . "' cookie is set.");
        }

        return "SSO-{$this->broker}-{$token}-" . $this->generateChecksum("bearer:$verificationCode");
    }

    /**
     * Generate session token.
     */
    protected function generateToken(): void
    {
        $this->token = base_convert(bin2hex(random_bytes(32)), 16, 36);
        $this->state[$this->getCookieName('token')] = $this->token;
    }

    /**
     * Clears session token.
     */
    public function clearToken(): void
    {
        unset($this->state[$this->getCookieName('token')]);
        unset($this->state[$this->getCookieName('verify')]);

        $this->token = null;
        $this->verificationCode = null;
    }

    /**
     * Check if we have an SSO token.
     */
    public function isAttached(): bool
    {
        return $this->getVerificationCode() !== null;
    }

    /**
     * Get URL to attach session at SSO server.
     *
     * @param array<string,mixed> $params
     * @return string
     */
    public function getAttachUrl(array $params = []): string
    {
        if ($this->getToken() === null) {
            $this->generateToken();
        }

        $data = [
            'broker' => $this->broker,
            'token' => $this->getToken(),
            'checksum' => $this->generateChecksum('attach')
        ];

        return $this->url . "?" . http_build_query($data + $params);
    }

    /**
     * Verify attaching to the SSO server by providing the verification code.
     */
    public function verify(string $code): void
    {
        $this->initialize();

        if ($this->verificationCode === $code) {
            return;
        }

        if ($this->verificationCode !== null) {
            trigger_error("SSO attach already verified", E_USER_WARNING);
            return;
        }

        $this->verificationCode = $code;
        $this->state[$this->getCookieName('verify')] = $code;
    }

    /**
     * Generate checksum for a broker.
     */
    protected function generateChecksum(string $command): string
    {
        return base_convert(hash_hmac('sha256', $command . ':' . $this->token, $this->secret), 16, 36);
    }

    /**
     * Get the request url for a command
     *
     * @param string                     $path
     * @param array<string,mixed>|string $params   Query parameters
     * @return string
     */
    protected function getRequestUrl(string $path, $params = ''): string
    {
        $query = is_array($params) ? http_build_query($params) : $params;

        $base = $path[0] === '/'
            ? preg_replace('~^(\w+://[^/]+).*~', '$1', $this->url)
            : preg_replace('~/[^/]*$~', '', $this->url);

        return $base . '/' . ltrim($path, '/') . ($query !== '' ? '?' . $query : '');
    }


    /**
     * Send an HTTP request to the SSO server.
     *
     * @param string                     $method  HTTP method: 'GET', 'POST', 'DELETE'
     * @param string                     $path    Relative path
     * @param array<string,mixed>|string $data    Query or post parameters
     * @return mixed
     * @throws RequestException
     */
    public function request(string $method, string $path, $data = '')
    {
        $url = $this->getRequestUrl($path, $method === 'POST' ? '' : $data);
        $headers = [
            'Accept: application/json',
            'Authorization: Bearer ' . $this->getBearerToken()
        ];

        ['httpCode' => $httpCode, 'contentType' => $contentType, 'body' => $body] =
            $this->getCurl()->request($method, $url, $headers, $method === 'POST' ? $data : '');

        return $this->handleResponse($httpCode, $contentType, $body);
    }

    /**
     * Handle the response of the cURL request.
     *
     * @param int    $httpCode  HTTP status code
     * @param string|null $ctHeader  Content-Type header
     * @param string $body      Response body
     * @return mixed
     * @throws RequestException
     */
    protected function handleResponse(int $httpCode, $ctHeader, string $body)
    {
        if ($httpCode === 204) {
            return null;
        }

        [$contentType] = explode(';', $ctHeader, 2);

        if ($contentType != 'application/json') {
            throw new RequestException(
                "Expected 'application/json' response, got '$contentType'",
                500,
                new RequestException($body, $httpCode)
            );
        }

        try {
            $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException $exception) {
            throw new RequestException("Invalid JSON response from server", 500, $exception);
        }

        if ($httpCode >= 400) {
            throw new RequestException($data['error'] ?? $body, $httpCode);
        }

        return $data;
    }
}


================================================
FILE: src/Broker/Cookies.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\SSO\Broker;

/**
 * Use global $_COOKIE and setcookie() to persist the client token.
 *
 * @implements \ArrayAccess<string,mixed>
 * @codeCoverageIgnore
 */
class Cookies implements \ArrayAccess
{
    /** @var int */
    protected int $ttl;

    /** @var string */
    protected string $path;

    /** @var string */
    protected string $domain;

    /** @var bool */
    protected bool $secure;

    public function __construct(int $ttl = 3600, string $path = '', string $domain = '', bool $secure = false)
    {
        $this->ttl = $ttl;
        $this->path = $path;
        $this->domain = $domain;
        $this->secure = $secure;
    }

    /**
     * @inheritDoc
     */
    public function offsetExists(mixed $offset): bool
    {
        return isset($_COOKIE[$offset]);
    }

    /**
     * @inheritDoc
     */
    public function offsetGet(mixed $offset): mixed
    {
        return $_COOKIE[$offset] ?? null;
    }

    /**
     * @inheritDoc
     */
    public function offsetSet(mixed $offset, mixed $value): void
    {
        $success = setcookie($offset, $value, time() + $this->ttl, $this->path, $this->domain, $this->secure, true);

        if (!$success) {
            throw new \RuntimeException("Failed to set cookie '$offset'");
        }

        $_COOKIE[$offset] = $value;
    }

    /**
     * @inheritDoc
     */
    public function offsetUnset(mixed $offset): void
    {
        setcookie($offset, '', 1, $this->path, $this->domain, $this->secure, true);
        unset($_COOKIE[$offset]);
    }
}


================================================
FILE: src/Broker/Curl.php
================================================
<?php /** @noinspection PhpComposerExtensionStubsInspection */

declare(strict_types=1);

namespace Jasny\SSO\Broker;

/**
 * Wrapper for cURL.
 *
 * @codeCoverageIgnore
 */
class Curl
{
    /**
     * Curl constructor.
     *
     * @throws \Exception if curl extension isn't loaded
     */
    public function __construct()
    {
        if (!extension_loaded('curl')) {
            throw new \Exception("cURL extension not loaded");
        }
    }

    /**
     * Send an HTTP request to the SSO server.
     *
     * @param string                     $method   HTTP method: 'GET', 'POST', 'DELETE'
     * @param string                     $url      Full URL
     * @param string[]                   $headers  HTTP headers
     * @param array<string,mixed>|string $data     Query or post parameters
     * @return array{httpCode:int,contentType:string,body:string}
     * @throws RequestException
     */
    public function request(string $method, string $url, array $headers, $data = '')
    {
        $ch = curl_init($url);

        if ($ch === false) {
            throw new \RuntimeException("Failed to initialize a cURL session");
        }

        if ($data !== [] && $data !== '') {
            $post = is_string($data) ? $data : http_build_query($data);
            curl_setopt($ch, CURLOPT_POSTFIELDS, $post);
        }

        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

        $responseBody = (string)curl_exec($ch);

        if (curl_errno($ch) != 0) {
            throw new RequestException('Server request failed: ' . curl_error($ch));
        }

        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? 'text/html';

        return ['httpCode' => $httpCode, 'contentType' => $contentType, 'body' => $responseBody];
    }
}


================================================
FILE: src/Broker/NotAttachedException.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\SSO\Broker;

/**
 * Exception thrown when a request is done while no session is attached
 */
class NotAttachedException extends \RuntimeException
{
}


================================================
FILE: src/Broker/RequestException.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\SSO\Broker;

/**
 * SSO Request failed.
 */
class RequestException extends \RuntimeException
{
}


================================================
FILE: src/Broker/Session.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\SSO\Broker;

/**
 * Use global $_SESSION to persist the client token.
 *
 * @implements \ArrayAccess<string,mixed>
 * @codeCoverageIgnore
 */
class Session implements \ArrayAccess
{
    /**
     * @inheritDoc
     */
    public function offsetSet($name, $value): void
    {
        $_SESSION[$name] = $value;
    }

    /**
     * @inheritDoc
     */
    public function offsetUnset($name): void
    {
        unset($_SESSION[$name]);
    }

    /**
     * @inheritDoc
     */
    public function offsetGet($name)
    {
        return $_SESSION[$name] ?? null;
    }

    /**
     * @inheritDoc
     */
    public function offsetExists($name): bool
    {
        return isset($_SESSION[$name]);
    }
}


================================================
FILE: src/Server/BrokerException.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\SSO\Server;

/**
 * Exception that's thrown if request from broker is invalid.
 * Should result in an HTTP 4xx response.
 */
class BrokerException extends \RuntimeException implements ExceptionInterface
{
}


================================================
FILE: src/Server/ExceptionInterface.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\SSO\Server;

interface ExceptionInterface
{
    /**
     * Gets the Exception message.
     *
     * @return string
     */
    public function getMessage();

    /**
     * Gets the Exception code.
     *
     * @return int
     */
    public function getCode();
}


================================================
FILE: src/Server/GlobalSession.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\SSO\Server;

/**
 * Interact with the global session using PHP's session_* functions.
 *
 * @codeCoverageIgnore
 */
class GlobalSession implements SessionInterface
{
    /**
     * Options passed to session_start().
     * @var array<string,mixed>
     */
    protected $options;

    /**
     * Class constructor.
     *
     * @param array<string,mixed> $options  Options passed to session_start().
     */
    public function __construct(array $options = [])
    {
        $this->options = $options + ['cookie_samesite' => 'None', 'cookie_secure' => true];
    }

    /**
     * @inheritDoc
     */
    public function getId(): string
    {
        return session_id();
    }

    /**
     * @inheritDoc
     */
    public function start(): void
    {
        $started = session_status() !== PHP_SESSION_ACTIVE
            ? session_start($this->options)
            : true;

        if (!$started) {
            $err = error_get_last() ?? ['message' => 'Failed to start session'];
            throw new ServerException($err['message'], 500);
        }

        // Session shouldn't be empty when resumed.
        $_SESSION['_sso_init'] = 1;
    }

    /**
     * @inheritDoc
     */
    public function resume(string $id): void
    {
        session_id($id);
        $started = session_start($this->options);

        if (!$started) {
            $err = error_get_last() ?? ['message' => 'Failed to start session'];
            throw new ServerException($err['message'], 500);
        }

        if ($_SESSION === []) {
            session_abort();
            throw new BrokerException("Session has expired. Client must attach with new token.", 401);
        }
    }

    /**
     * @inheritDoc
     */
    public function isActive(): bool
    {
        return session_status() === PHP_SESSION_ACTIVE;
    }
}


================================================
FILE: src/Server/Server.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\SSO\Server;

use Jasny\Immutable;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Psr\SimpleCache\CacheInterface;

/**
 * Single sign-on server.
 * The SSO server is responsible of managing users sessions which are available for brokers.
 */
class Server
{
    use Immutable\With;

    /**
     * Callback to get the secret for a broker.
     * @var \Closure
     */
    protected $getBrokerInfo;

    /**
     * Storage for broker session links.
     * @var CacheInterface
     */
    protected $cache;

    /**
     * @var LoggerInterface
     */
    protected $logger;

    /**
     * Service to interact with sessions.
     * @var SessionInterface
     */
    protected $session;

    /**
     * Class constructor.
     *
     * @phpstan-param callable(string):?array{secret:string,domains:string[]} $getBrokerInfo
     * @phpstan-param CacheInterface                                          $cache
     */
    public function __construct(callable $getBrokerInfo, CacheInterface $cache)
    {
        $this->getBrokerInfo = \Closure::fromCallable($getBrokerInfo);
        $this->cache = $cache;

        $this->logger = new NullLogger();
        $this->session = new GlobalSession();
    }

    /**
     * Get a copy of the service with logging.
     *
     * @return static
     */
    public function withLogger(LoggerInterface $logger): self
    {
        return $this->withProperty('logger', $logger);
    }

    /**
     * Get a copy of the service with a custom session service.
     *
     * @return static
     */
    public function withSession(SessionInterface $session): self
    {
        return $this->withProperty('session', $session);
    }


    /**
     * Start the session for broker requests to the SSO server.
     *
     * @throws BrokerException
     * @throws ServerException
     */
    public function startBrokerSession(?ServerRequestInterface $request = null): void
    {
        if ($this->session->isActive()) {
            throw new ServerException("Session is already started", 500);
        }

        $bearer = $this->getBearerToken($request);

        [$brokerId, $token, $checksum] = $this->parseBearer($bearer);

        $sessionId = $this->cache->get($this->getCacheKey($brokerId, $token));

        if ($sessionId === null) {
            $this->logger->warning(
                "Bearer token isn't attached to a client session",
                ['broker' => $brokerId, 'token' => $token]
            );
            throw new BrokerException("Bearer token isn't attached to a client session", 403);
        }

        $code = $this->getVerificationCode($brokerId, $token, $sessionId);
        $this->validateChecksum($checksum, 'bearer', $brokerId, $token, $code);

        $this->session->resume($sessionId);

        $this->logger->debug(
            "Broker request with session",
            ['broker' => $brokerId, 'token' => $token, 'session' => $sessionId]
        );
    }

    /**
     * Get bearer token from Authorization header.
     */
    protected function getBearerToken(?ServerRequestInterface $request = null): string
    {
        $authorization = $request === null
            ? ($_SERVER['HTTP_AUTHORIZATION'] ?? '') // @codeCoverageIgnore
            : $request->getHeaderLine('Authorization');

        [$type, $token] = explode(' ', $authorization, 2) + ['', ''];

        if ($type !== 'Bearer') {
            $this->logger->warning("Broker didn't use bearer authentication: "
                . ($authorization === '' ? "No 'Authorization' header" : "$type authorization used"));
            throw new BrokerException("Broker didn't use bearer authentication", 401);
        }

        return $token;
    }

    /**
     * Get the broker id and token from the bearer token used by the broker.
     *
     * @return string[]
     * @throws BrokerException
     */
    protected function parseBearer(string $bearer): array
    {
        $matches = null;

        if (!(bool)preg_match('/^SSO-(\w*+)-(\w*+)-([a-z0-9]*+)$/', $bearer, $matches)) {
            $this->logger->warning("Invalid bearer token", ['bearer' => $bearer]);
            throw new BrokerException("Invalid bearer token", 403);
        }

        return array_slice($matches, 1);
    }

    /**
     * Generate cache key for linking the broker token to the client session.
     */
    protected function getCacheKey(string $brokerId, string $token): string
    {
        return "SSO-{$brokerId}-{$token}";
    }

    /**
     * Get the broker secret using the configured callback.
     *
     * @param string $brokerId
     * @return string|null
     */
    protected function getBrokerSecret(string $brokerId): ?string
    {
        return ($this->getBrokerInfo)($brokerId)['secret'] ?? null;
    }

    /**
     * Generate the verification code based on the token using the server secret.
     */
    protected function getVerificationCode(string $brokerId, string $token, string $sessionId): string
    {
        return base_convert(hash('sha256', $brokerId . $token . $sessionId), 16, 36);
    }

    /**
     * Generate checksum for a broker.
     */
    protected function generateChecksum(string $command, string $brokerId, string $token): string
    {
        $secret = $this->getBrokerSecret($brokerId);

        if ($secret === null) {
            $this->logger->warning("Unknown broker", ['broker' => $brokerId, 'token' => $token]);
            throw new BrokerException("Broker is unknown or disabled", 403);
        }

        return base_convert(hash_hmac('sha256', $command . ':' . $token, $secret), 16, 36);
    }

    /**
     * Assert that the checksum matches the expected checksum.
     *
     * @throws BrokerException
     */
    protected function validateChecksum(
        string $checksum,
        string $command,
        string $brokerId,
        string $token,
        ?string $code = null
    ): void {
        $expected = $this->generateChecksum($command . ($code !== null ? ":$code" : ''), $brokerId, $token);

        if ($checksum !== $expected) {
            $this->logger->warning(
                "Invalid $command checksum",
                ['expected' => $expected, 'received' => $checksum, 'broker' => $brokerId, 'token' => $token]
                    + ($code !== null ? ['verification_code' => $code] : [])
            );
            throw new BrokerException("Invalid $command checksum", 403);
        }
    }

    /**
     * Validate that the URL has a domain that is allowed for the broker.
     */
    public function validateDomain(string $type, string $url, string $brokerId, ?string $token = null): void
    {
        $domains = ($this->getBrokerInfo)($brokerId)['domains'] ?? [];
        $host = parse_url($url, PHP_URL_HOST);

        if (!in_array($host, $domains, true)) {
            $this->logger->warning(
                "Domain of $type is not allowed for broker",
                [$type => $url, 'broker' => $brokerId] + ($token !== null ? ['token' => $token] : [])
            );
            throw new BrokerException("Domain of $type is not allowed", 400);
        }
    }

    /**
     * Attach a client session to a broker session.
     * Returns the verification code.
     *
     * @throws BrokerException
     * @throws ServerException
     */
    public function attach(?ServerRequestInterface $request = null): string
    {
        ['broker' => $brokerId, 'token' => $token] = $this->processAttachRequest($request);

        $this->session->start();

        $this->assertNotAttached($brokerId, $token);

        $key = $this->getCacheKey($brokerId, $token);
        $cached = $this->cache->set($key, $this->session->getId());

        $info = ['broker' => $brokerId, 'token' => $token, 'session' => $this->session->getId()];

        if (!$cached) {
            $this->logger->error("Failed to attach bearer token to session id due to cache issue", $info);
            throw new ServerException("Failed to attach bearer token to session id", 500);
        }

        $this->logger->info("Attached broker token to session", $info);

        return $this->getVerificationCode($brokerId, $token, $this->session->getId());
    }

    /**
     * Assert that the token isn't already attached to a different session.
     */
    protected function assertNotAttached(string $brokerId, string $token): void
    {
        $key = $this->getCacheKey($brokerId, $token);
        $attached = $this->cache->get($key);

        if ($attached !== null && $attached !== $this->session->getId()) {
            $this->logger->warning("Token is already attached", [
                'broker' => $brokerId,
                'token' => $token,
                'attached_to' => $attached,
                'session' => $this->session->getId()
            ]);
            throw new BrokerException("Token is already attached", 400);
        }
    }

    /**
     * Validate attach request and return broker id and token.
     *
     * @param ServerRequestInterface|null $request
     * @return array{broker:string,token:string}
     * @throws BrokerException
     */
    protected function processAttachRequest(?ServerRequestInterface $request): array
    {
        $brokerId = $this->getRequiredQueryParam($request, 'broker');
        $token = $this->getRequiredQueryParam($request, 'token');
        $checksum = $this->getRequiredQueryParam($request, 'checksum');

        $this->validateChecksum($checksum, 'attach', $brokerId, $token);

        $origin = $this->getHeader($request, 'Origin');
        if ($origin !== '') {
            $this->validateDomain('origin', $origin, $brokerId, $token);
        }

        $referer = $this->getHeader($request, 'Referer');
        if ($referer !== '') {
            $this->validateDomain('referer', $referer, $brokerId, $token);
        }

        $returnUrl = $this->getQueryParam($request, 'return_url');
        if ($returnUrl !== null) {
            $this->validateDomain('return_url', $returnUrl, $brokerId, $token);
        }

        return ['broker' => $brokerId, 'token' => $token];
    }

    /**
     * Get query parameter from PSR-7 request or $_GET.
     */
    protected function getQueryParam(?ServerRequestInterface $request, string $key): ?string
    {
        $params = $request === null
            ? $_GET // @codeCoverageIgnore
            : $request->getQueryParams();

        return $params[$key] ?? null;
    }

    /**
     * Get required query parameter from PSR-7 request or $_GET.
     *
     * @throws BrokerException if query parameter isn't set
     */
    protected function getRequiredQueryParam(?ServerRequestInterface $request, string $key): string
    {
        $value = $this->getQueryParam($request, $key);

        if ($value === null) {
            throw new BrokerException("Missing '$key' query parameter", 400);
        }

        return $value;
    }

    /**
     * Get HTTP Header from PSR-7 request or $_SERVER.
     *
     * @param ServerRequestInterface $request
     * @param string                 $key
     * @return string
     */
    protected function getHeader(?ServerRequestInterface $request, string $key): string
    {
        return $request === null
            ? ($_SERVER['HTTP_' . str_replace('-', '_', strtoupper($key))] ?? '') // @codeCoverageIgnore
            : $request->getHeaderLine($key);
    }
}


================================================
FILE: src/Server/ServerException.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\SSO\Server;

/**
 * Exception that's thrown if something unexpectedly went wrong on the server.
 * Should result in an HTTP 5xx response.
 */
class ServerException extends \RuntimeException implements ExceptionInterface
{
}


================================================
FILE: src/Server/SessionInterface.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\SSO\Server;

/**
 * Interface to start a session.
 */
interface SessionInterface
{
    /**
     * @see session_id()
     */
    public function getId(): string;

    /**
     * Start a new session.
     * @see session_start()
     *
     * @throws ServerException if session can't be started.
     */
    public function start(): void;

    /**
     * Resume an existing session.
     *
     * @throws ServerException if session can't be started.
     * @throws BrokerException if session is expired
     */
    public function resume(string $id): void;

    /**
     * Check if a session is active. (status PHP_SESSION_ACTIVE)
     * @see session_status()
     */
    public function isActive(): bool;
}


================================================
FILE: tests/_bootstrap.php
================================================
<?php

define('ROOT_DIR', dirname(__DIR__));

require_once ROOT_DIR . '/vendor/autoload.php';


================================================
FILE: tests/_output/.gitignore
================================================
*
!.gitignore

================================================
FILE: tests/_support/DemoTester.php
================================================
<?php


/**
 * Inherited Methods
 * @method void wantToTest($text)
 * @method void wantTo($text)
 * @method void execute($callable)
 * @method void expectTo($prediction)
 * @method void expect($prediction)
 * @method void amGoingTo($argumentation)
 * @method void am($role)
 * @method void lookForwardTo($achieveValue)
 * @method void comment($description)
 * @method void pause()
 *
 * @SuppressWarnings(PHPMD)
*/
class DemoTester extends \Codeception\Actor
{
    use _generated\DemoTesterActions;

   /**
    * Define custom actions here
    */
}


================================================
FILE: tests/_support/Helper/Demo.php
================================================
<?php

declare(strict_types=1);

namespace Helper;

use Codeception\Module\PhpBrowser;
use PhpBuiltInServer;

// here you can define custom actions
// all public methods declared in helper class will be available in $I

class Demo extends \Codeception\Module
{
    protected $server;
    protected $broker1;
    protected $broker2;

    /**
     * Hook runs before any test of the suite is run
     *
     * @param array $settings
     */
    public function _beforeSuite($settings = [])
    {
        parent::_beforeSuite($settings);

        $this->server = new PhpBuiltInServer(ROOT_DIR . '/demo/server/', 8200);

        $this->broker1 = new PhpBuiltInServer(
            ROOT_DIR . '/demo/broker/',
            8201,
            [
                'SSO_SERVER' => 'http://localhost:8200/attach.php',
                'SSO_BROKER_ID' => 'Alice',
                'SSO_BROKER_SECRET' => '8iwzik1bwd'
            ]
        );

        $this->broker2 = new PhpBuiltInServer(
            ROOT_DIR . '/demo/broker/',
            8202,
            [
                'SSO_SERVER' => 'http://localhost:8200/attach.php',
                'SSO_BROKER_ID' => 'Greg',
                'SSO_BROKER_SECRET' => '7pypoox2pc'
            ]
        );
    }

    /**
     * Hook runs after all test of the suite is run
     */
    public function _afterSuite()
    {
        $this->server = null;
        $this->broker1 = null;
        $this->broker2 = null;

        parent::_afterSuite();
    }

    /**
     * Set URL of broker as base host.
     *
     * @param int $nr
     */
    public function amOnBroker(int $nr): void
    {
        if ($nr < 1 || $nr > 2) {
            throw new \Exception("Invalid broker number $nr");
        }

        $port = $nr + 8200;

        /** @var PhpBrowser $phpBrowser */
        $phpBrowser =$this->getModule('PhpBrowser');

        $phpBrowser->amOnUrl("http://localhost:$port");
    }
}


================================================
FILE: tests/_support/Helper/Unit.php
================================================
<?php

declare(strict_types=1);

namespace Helper;

// here you can define custom actions
// all public methods declared in helper class will be available in $I

class Unit extends \Codeception\Module
{

}


================================================
FILE: tests/_support/PhpBuiltInServer.php
================================================
<?php

use Codeception\Configuration;

/**
 * Start/stop the PHP built-in web server on localhost
 */
class PhpBuiltInServer
{
    /**
     * HTTP port
     * @var int
     */
    protected $port;
    
    /**
     * @var resource
     */
    protected $handle;

    /**
     * @var resource[]
     */
    protected $pipes;

    /**
     * Class constructor
     *
     * @param string   $documentRoot  Path to router file.
     * @param int      $port
     * @param string[] $env           Environment variables
     */
    public function __construct(string $documentRoot, int $port, array $env = [])
    {
        $this->port = $port;
        
        $this->run($documentRoot, $env);
        $this->testConnection();
    }

    /**
     * Start the web server
     *
     * @param string   $documentRoot  Path to router file.
     * @param string[] $env           Environment variables
     */
    protected function run(string $documentRoot, array $env): void
    {
        if ($this->handle) {
            trigger_error("Built-in webserver on port {$this->port} already started", E_USER_NOTICE);
            return;
        }

        $cmd = $this->getCommand($documentRoot);
        $descriptorSpec = [
            ["pipe", "r"],
            ['file', Configuration::logDir() . "phpbuiltinserver.{$this->port}.output.txt", 'w'],
            ['file', Configuration::logDir() . "phpbuiltinserver.{$this->port}.errors.txt", 'a']
        ];
        $pipes = [];

        $this->handle = proc_open($cmd, $descriptorSpec, $this->pipes, ROOT_DIR, $env, ['bypass_shell' => true]);
        fclose($this->pipes[0]); // close stdin

        $this->registerShutdown();

        usleep(10000);
        $status = proc_get_status($this->handle);

        if (!$status['running']) {
            proc_close($this->handle);

            $error = stream_get_contents($pipes[2]) ?: stream_get_contents($pipes[1]);
            throw new \Exception("Failed to start PHP built-in web server. $error");
        }
    }

    /**
     * Get the executable command to start the webserver.
     */
    protected function getCommand(string $documentRoot): string
    {
        // Platform uses POSIX process handling. Use exec to avoid controlling the shell process instead of the PHP
        // interpreter.
        $exec = (PHP_OS !== 'WINNT' && PHP_OS !== 'WIN32') ? 'exec ' : '';

        return $exec . escapeshellcmd(PHP_BINARY)
            . " -S localhost:{$this->port}"
            . " -t " . escapeshellarg($documentRoot)
            . ($this->isRemoteDebug() ? ' -dxdebug.remote_enable=1' : '');
    }

    /**
     * Check if codeception remote debugging is available.
     */
    protected function isRemoteDebug(): bool
    {
        return Configuration::isExtensionEnabled('Codeception\Extension\RemoteDebug');
    }

    /**
     * Make sure we can connect to the webserver
     */
    protected function testConnection()
    {
        for ($i=0; $i < 5; $i++) {
            if ($this->connect()) {
                return;
            }
            sleep(1);
        }
        
        $err = error_get_last();
        throw new \Exception("Failed to connect to built-in web server: {$err['message']}");
    }

    /**
     * Connect to the webserver
     */
    protected function connect(): bool
    {
        $sock = @fsockopen('localhost', $this->port, $errno, $errstr, 1);

        return is_resource($sock) && $errno === 0;
    }
    
    /**
     * Stop the web server
     */
    public function __destruct()
    {
        $this->stop();
    }

    /**
     * Stop the web server
     */
    public function stop(): void
    {
        if ($this->handle === null) {
            return;
        }

        foreach ($this->pipes as $pipe) {
            if (is_resource($pipe)) {
                fclose($pipe);
            }
        }

        proc_terminate($this->handle, 15);
        unset($this->handle);
    }

    /**
     * Register shutdown function to stop webserver on an error.
     */
    protected function registerShutdown(): void
    {
        $handle = $this->handle;

        register_shutdown_function(function () use ($handle) {
            if (is_resource($handle)) {
                proc_terminate($handle);
            }
        });
    }
}


================================================
FILE: tests/_support/UnitTester.php
================================================
<?php


/**
 * Inherited Methods
 * @method void wantToTest($text)
 * @method void wantTo($text)
 * @method void execute($callable)
 * @method void expectTo($prediction)
 * @method void expect($prediction)
 * @method void amGoingTo($argumentation)
 * @method void am($role)
 * @method void lookForwardTo($achieveValue)
 * @method void comment($description)
 * @method void pause()
 *
 * @SuppressWarnings(PHPMD)
*/
class UnitTester extends \Codeception\Actor
{
    use _generated\UnitTesterActions;

   /**
    * Define custom actions here
    */
}


================================================
FILE: tests/demo/DemoCept.php
================================================
<?php

/** @var \Codeception\Scenario $scenario */
$I = new DemoTester($scenario);
$I->wantTo("login at broker 1 and see I'm also logged in at broker 2");

// ---
$I->amGoingTo("login at Alice (broker 1)");

$I->amOnBroker(1);
$I->see('Alice');
$I->see('Logged out');

$I->click('Login');
$I->seeElement('form', ['action' => 'login.php']);
$I->submitForm('form', [
    'username' => 'john',
    'password' => 'john123'
]);

$I->see('Logged in');
$I->see('John Doe');
$I->see('john.doe@example.com');

// ---
$I->amGoingTo("visit Greg (broker 2)");
$I->expect("john to be logged in through SSO");

$I->amOnBroker(2);
$I->see('Greg');

$I->see('Logged in');
$I->see('John Doe');
$I->see('john.doe@example.com');

// ---
$I->amGoingTo("logout at Greg (broker 2)");

$I->amOnBroker(2);
$I->see('Greg');

$I->click('Logout');
$I->see('Logged out');

// ---
$I->amGoingTo("visit Alice (broker 1)");
$I->expect("john to be logged out through SSO");

$I->amOnBroker(1);
$I->see('Alice');

$I->see('Logged out');


================================================
FILE: tests/demo.suite.yml
================================================
actor: DemoTester
modules:
    enabled:
        - \Helper\Demo
        - PhpBrowser:
              url: 'http://localhost:8201'

================================================
FILE: tests/unit/Broker/AttachTest.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\Tests\SSO\Broker;

use Jasny\PHPUnit\ExpectWarningTrait;
use Jasny\PHPUnit\SafeMocksTrait;
use Jasny\SSO\Broker\Broker;
use Jasny\SSO\Broker\Curl;
use Jasny\SSO\Broker\NotAttachedException;
use Jasny\Tests\SSO\TokenTrait;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

/**
 * Test methods for attaching the broker token to a client session.
 *
 * @covers \Jasny\SSO\Broker\Broker
 */
class AttachTest extends TestCase
{
    use TokenTrait;
    use SafeMocksTrait;
    use ExpectWarningTrait;

    /**
     * @var \ArrayObject
     */
    protected $session;

    /**
     * @var Curl&MockObject
     */
    protected $curl;

    /**
     * @var Broker
     */
    protected $broker;

    public function setUp(): void
    {
        $this->session = new \ArrayObject();
        $this->curl = $this->createMock(Curl::class);

        $this->broker = (new Broker('https://example.com/attach', 'foo', 'bar'))
            ->withTokenIn($this->session)
            ->withCurl($this->curl);
    }

    public function testUrlValidationInConstruct()
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage("Invalid SSO server URL 'example'");

        new Broker('example', 'foo', 'bar');
    }

    public function testBrokerIdValidationInConstruct()
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage("Invalid broker id 'foo-1': must be alphanumeric");

        new Broker('https://example.com', 'foo-1', 'bar');
    }

    public function testGetBrokerId()
    {
        $this->assertEquals('foo', $this->broker->getBrokerId());
    }

    public function testGetAttachUrl()
    {
        $url = $this->broker->getAttachUrl();

        $this->assertArrayHasKey('sso_token_foo', $this->session);

        $token = $this->session["sso_token_foo"];
        $checksum = $this->generateChecksum('attach', 'bar', $token);

        $this->assertEquals("https://example.com/attach?broker=foo&token=$token&checksum=$checksum", $url);
        $this->assertFalse($this->broker->isAttached());
    }

    public function testGetAttachUrlWithParams()
    {
        $url = $this->broker->getAttachUrl([
            'return_url' => 'http://broker.example.com/',
            'color' => 'red',
        ]);

        $this->assertArrayHasKey('sso_token_foo', $this->session);

        $token = $this->session["sso_token_foo"];
        $checksum = $this->generateChecksum('attach', 'bar', $token);

        $expectedUrl = "https://example.com/attach?broker=foo&token=$token&checksum=$checksum&return_url="
            . urlencode('http://broker.example.com/') . '&color=red';
        $this->assertEquals($expectedUrl, $url);
    }

    public function testVerify()
    {
        $this->session['sso_token_foo'] = '123456';

        $this->assertFalse($this->broker->isAttached());

        $code = $this->getVerificationCode('foo', '123456', 'abc123');
        $this->broker->verify($code);

        $this->assertArrayHasKey('sso_verify_foo', $this->session);
        $this->assertEquals($code, $this->session['sso_verify_foo']);
        $this->assertTrue($this->broker->isAttached());
    }

    public function testVerifyIsIdempotent()
    {
        $code = $this->getVerificationCode('foo', '123456', 'abc123');

        $this->session['sso_token_foo'] = '123456';
        $this->session['sso_verify_foo'] = $code;

        $this->broker->verify($code);

        $this->assertArrayHasKey('sso_verify_foo', $this->session);
        $this->assertEquals($code, $this->session['sso_verify_foo']);
    }

    public function testVerifyIsImmutable()
    {
        $this->session['sso_token_foo'] = '123456';
        $this->session['sso_verify_foo'] = '000000';

        $code = $this->getVerificationCode('foo', '123456', 'abc123');

        $this->expectWarningMessage("SSO attach already verified");

        $this->broker->verify($code);

        $this->assertArrayHasKey('sso_verify_foo', $this->session);
        $this->assertEquals('000000', $this->session['sso_verify_foo']);
    }

    public function testClearToken()
    {
        $this->session['sso_token_foo'] = '123456';
        $this->session['sso_verify_foo'] = $this->getVerificationCode('foo', '123456', 'abc123');

        $this->assertTrue($this->broker->isAttached());

        $this->broker->clearToken();

        $this->assertFalse($this->broker->isAttached());
        $this->assertArrayNotHasKey('sso_token_foo', $this->session);
        $this->assertArrayNotHasKey('sso_verify_foo', $this->session);
    }
}


================================================
FILE: tests/unit/Broker/RequestTest.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\Tests\SSO\Broker;

use Jasny\PHPUnit\ExpectWarningTrait;
use Jasny\PHPUnit\SafeMocksTrait;
use Jasny\SSO\Broker\Broker;
use Jasny\SSO\Broker\Curl;
use Jasny\SSO\Broker\NotAttachedException;
use Jasny\SSO\Broker\RequestException;
use Jasny\Tests\SSO\TokenTrait;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

/**
 * Test broker methods for making API requests to the SSO server.
 *
 * @covers \Jasny\SSO\Broker\Broker
 */
class RequestTest extends TestCase
{
    use TokenTrait;
    use SafeMocksTrait;
    use ExpectWarningTrait;

    /**
     * @var \ArrayObject
     */
    protected $session;

    /**
     * @var Curl&MockObject
     */
    protected $curl;

    /**
     * @var Broker
     */
    protected $broker;

    public function setUp(): void
    {
        $this->session = new \ArrayObject([
            'sso_token_foo' => '123456',
            'sso_verify_foo' => $this->getVerificationCode('foo', '123456', 'abc123'),
        ]);
        $this->curl = $this->createMock(Curl::class);

        $this->broker = (new Broker('https://example.com/attach', 'foo', 'bar'))
            ->withTokenIn($this->session)
            ->withCurl($this->curl);
    }

    public function testGetBearerToken()
    {
        $this->assertTrue($this->broker->isAttached());

        $bearer = $this->broker->getBearerToken();

        $this->assertEquals(
            $this->getBearerToken('foo', 'bar', '123456', 'abc123'),
            $bearer
        );
    }

    public function testGetBearerTokenWhenNotAttached()
    {
        unset($this->session['sso_verify_foo']);

        $this->assertFalse($this->broker->isAttached());

        $this->expectException(NotAttachedException::class);
        $this->expectExceptionMessage("The client isn't attached to the SSO server for this broker. "
            . "Make sure that the 'sso_verify_foo' cookie is set.");

        $this->broker->getBearerToken();
    }


    public function testGetRequest()
    {
        $headers = [
            'Accept: application/json',
            'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123')
        ];
        $this->curl->expects($this->once())->method('request')
            ->with('GET', 'https://example.com/info', $headers, '')
            ->willReturn([
                'httpCode' => 200,
                'contentType' => 'application/json; charset=utf-8',
                'body' => '{"name": "John", "email": "john@example.com"}',
            ]);

        $info = $this->broker->request('GET', '/info');

        $this->assertEquals(['name' => 'John', 'email' => 'john@example.com'], $info);
    }

    public function testPostRequest()
    {
        $headers = [
            'Accept: application/json',
            'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123')
        ];
        $this->curl->expects($this->once())->method('request')
            ->with('POST', 'https://example.com/user', $headers, ['name' => 'John', 'color' => 'red'])
            ->willReturn([
                'httpCode' => 200,
                'contentType' => 'application/json; charset=utf-8',
                'body' => '{"name": "John", "email": "john@example.com"}',
            ]);

        $info = $this->broker->request('POST', '/user', ['name' => 'John', 'color' => 'red']);

        $this->assertEquals(['name' => 'John', 'email' => 'john@example.com'], $info);
    }

    public function testNoContent()
    {
        $headers = [
            'Accept: application/json',
            'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123')
        ];
        $this->curl->expects($this->once())->method('request')
            ->with('POST', 'https://example.com/go', $headers, '')
            ->willReturn([
                'httpCode' => 204,
                'contentType' => '',
                'body' => '',
            ]);

        $info = $this->broker->request('POST', '/go');

        $this->assertNull($info);
    }

    public function testBadRequest()
    {
        $headers = [
            'Accept: application/json',
            'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123')
        ];
        $this->curl->expects($this->once())->method('request')
            ->with('GET', 'https://example.com/', $headers, '')
            ->willReturn([
                'httpCode' => 400,
                'contentType' => 'application/json',
                'body' => '{"error": "something is wrong"}',
            ]);

        $this->expectException(RequestException::class);
        $this->expectExceptionMessage("something is wrong");

        $this->broker->request('GET', '/');
    }

    public function testInvalidContentType()
    {
        $headers = [
            'Accept: application/json',
            'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123')
        ];
        $this->curl->expects($this->once())->method('request')
            ->with('GET', 'https://example.com/', $headers, '')
            ->willReturn([
                'httpCode' => 200,
                'contentType' => 'text/html',
                'body' => '<h1>Foo</h1>',
            ]);

        $this->expectException(RequestException::class);
        $this->expectExceptionMessage("Expected 'application/json' response, got 'text/html'");

        $this->broker->request('GET', '/');
    }

    public function testInvalidJson()
    {
        $headers = [
            'Accept: application/json',
            'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123')
        ];
        $this->curl->expects($this->once())->method('request')
            ->with('GET', 'https://example.com/', $headers, '')
            ->willReturn([
                'httpCode' => 200,
                'contentType' => 'application/json',
                'body' => 'not json',
            ]);

        $this->expectException(RequestException::class);
        $this->expectExceptionMessage("Invalid JSON response from server");

        $this->broker->request('GET', '/');
    }
}


================================================
FILE: tests/unit/Server/AttachTest.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\Tests\SSO\Server;

use Jasny\HttpMessage\ServerRequest;
use Jasny\HttpMessage\Uri;
use Jasny\PHPUnit\CallbackMockTrait;
use Jasny\PHPUnit\SafeMocksTrait;
use Jasny\SSO\Server\BrokerException;
use Jasny\SSO\Server\Server;
use Jasny\SSO\Server\ServerException;
use Jasny\SSO\Server\SessionInterface;
use Jasny\Tests\SSO\TokenTrait;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
use function Jasny\array_without;

/**
 * Test Server::attach() and related methods.
 *
 * @covers \Jasny\SSO\Server\Server
 */
class AttachTest extends \Codeception\Test\Unit
{
    use TokenTrait;
    use CallbackMockTrait;
    use SafeMocksTrait;

    public function testSuccessfulAttach()
    {
        $callback = $this->createCallbackMock(
            $this->atLeastOnce(),
            ['foo'],
            ['secret' => 'bar', 'domains' => ['broker.example.com']]
        );

        $cache = $this->createMock(CacheInterface::class);

        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withQueryParams([
                'broker' => 'foo',
                'checksum' => $this->generateChecksum('attach', 'bar', '123456'),
                'token' => '123456',
                'return_url' => 'https://broker.example.com/attached'
            ])
            ->withHeader('Referer', 'https://broker.example.com/login')
            ->withHeader('Origin', 'https://broker.example.com/');

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $session->expects($this->once())->method('start')->id('start');
        $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123');

        $cache->expects($this->once())->method('get')
            ->with('SSO-foo-123456')
            ->willReturn(null);
        $cache->expects($this->once())->method('set')
            ->with('SSO-foo-123456', 'abc123')
            ->willReturn(true);

        $logger->expects($this->once())->method('info')
            ->with(
                "Attached broker token to session",
                ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123']
            );

        $code = $server->attach($request);

        $this->assertEquals(
            $this->getVerificationCode('foo', '123456', 'abc123'),
            $code
        );
    }

    public function missingQueryParameterProvider()
    {
        return [
            'broker' => ['broker'],
            'checksum' => ['checksum'],
            'token' => ['token'],
        ];
    }

    /**
     * @dataProvider missingQueryParameterProvider
     */
    public function testMissingQueryParameter(string $key)
    {
        $callback = $this->createCallbackMock($this->never());

        $cache = $this->createMock(CacheInterface::class);

        $queryParams = [
            'broker' => 'foo',
            'checksum' => $this->generateChecksum('attach', 'bar', '123456'),
            'token' => '123456',
            'return_url' => 'https://return_url.example.com/'
        ];

        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withQueryParams(array_without($queryParams, [$key]))
            ->withHeader('Referer', 'https://referer.example.com/')
            ->withHeader('Origin', 'https://origin.example.com/');

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $session->expects($this->never())->method('start');

        $cache->expects($this->never())->method('get');
        $cache->expects($this->never())->method('set');

        $this->expectException(BrokerException::class);
        $this->expectExceptionMessage("Missing '$key' query parameter");

        $server->attach($request);
    }

    public function domainProvider()
    {
        return [
            'return_url' => ['return_url', ['origin.example.com', 'referer.example.com']],
            'origin' => ['origin', ['referer.example.com', 'return_url.example.com']],
            'referer' => ['referer', ['origin.example.com', 'return_url.example.com']],
        ];
    }

    /**
     * @dataProvider domainProvider
     */
    public function testInvalidDomain(string $type, array $domains)
    {
        $callback = $this->createCallbackMock(
            $this->atLeastOnce(),
            ['foo'],
            ['secret' => 'bar', 'domains' => $domains]
        );

        $cache = $this->createMock(CacheInterface::class);

        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withQueryParams([
                'broker' => 'foo',
                'checksum' => $this->generateChecksum('attach', 'bar', '123456'),
                'token' => '123456',
                'return_url' => 'https://return_url.example.com/'
            ])
            ->withHeader('Referer', 'https://referer.example.com/')
            ->withHeader('Origin', 'https://origin.example.com/');

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $session->expects($this->never())->method('start');

        $cache->expects($this->never())->method('get');
        $cache->expects($this->never())->method('set');

        $logger->expects($this->once())->method('warning')
            ->with(
                "Domain of $type is not allowed for broker",
                [$type => "https://$type.example.com/", 'broker' => 'foo', 'token' => '123456']
            );

        $this->expectException(BrokerException::class);
        $this->expectExceptionMessage("Domain of $type is not allowed");

        $server->attach($request);
    }

    public function testInvalidChecksum()
    {
        $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']);

        $cache = $this->createMock(CacheInterface::class);

        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withQueryParams([
                'broker' => 'foo',
                'checksum' => '0000000000',
                'token' => '123456'
            ]);

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $cache->expects($this->never())->method('get');
        $cache->expects($this->never())->method('set');

        $checksum = $this->generateChecksum('attach', 'bar', '123456');
        $logger->expects($this->once())->method('warning')
            ->with(
                "Invalid attach checksum",
                ['expected' => $checksum, 'received' => '0000000000', 'broker' => 'foo', 'token' => '123456']
            );

        $this->expectException(BrokerException::class);
        $this->expectExceptionMessage("Invalid attach checksum");

        $server->attach($request);
    }

    public function testUnknownBroker()
    {
        $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], null);

        $cache = $this->createMock(CacheInterface::class);

        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withQueryParams([
                'broker' => 'foo',
                'checksum' => $this->generateChecksum('attach', 'bar', '123456'),
                'token' => '123456'
            ]);

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $cache->expects($this->never())->method('get');
        $cache->expects($this->never())->method('set');

        $logger->expects($this->once())->method('warning')
            ->with("Unknown broker", ['broker' => 'foo', 'token' => '123456']);

        $this->expectException(BrokerException::class);
        $this->expectExceptionMessage("Broker is unknown or disabled");

        $server->attach($request);
    }

    public function testAlreadyAttached()
    {
        $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']);

        $cache = $this->createMock(CacheInterface::class);

        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withQueryParams([
                'broker' => 'foo',
                'checksum' => $this->generateChecksum('attach', 'bar', '123456'),
                'token' => '123456'
            ]);

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $session->expects($this->once())->method('start')->id('start');
        $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123');

        $cache->expects($this->once())->method('get')
            ->with('SSO-foo-123456')
            ->willReturn('xyz543');
        $cache->expects($this->never())->method('set');

        $logger->expects($this->once())->method('warning')
            ->with(
                "Token is already attached",
                ['broker' => 'foo', 'token' => '123456', 'attached_to' => 'xyz543', 'session' => 'abc123']
            );

        $this->expectException(BrokerException::class);
        $this->expectExceptionMessage("Token is already attached");

        $server->attach($request);
    }

    public function testAttachIsIdempotent()
    {
        $callback = $this->createCallbackMock(
            $this->atLeastOnce(),
            ['foo'],
            ['secret' => 'bar', 'domains' => ['broker.example.com']]
        );

        $cache = $this->createMock(CacheInterface::class);

        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withQueryParams([
                'broker' => 'foo',
                'checksum' => $this->generateChecksum('attach', 'bar', '123456'),
                'token' => '123456',
                'return_url' => 'https://broker.example.com/attached'
            ])
            ->withHeader('Referer', 'https://broker.example.com/login')
            ->withHeader('Origin', 'https://broker.example.com/');

        $session = $this->createMock(SessionInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session);

        $session->expects($this->once())->method('start')->id('start');
        $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123');

        $cache->expects($this->once())->method('get')
            ->with('SSO-foo-123456')
            ->willReturn('abc123');
        $cache->expects($this->once())->method('set')
            ->with('SSO-foo-123456', 'abc123')
            ->willReturn(true);

        $code = $server->attach($request);

        $this->assertEquals(
            $this->getVerificationCode('foo', '123456', 'abc123'),
            $code
        );
    }

    public function testCacheIssue()
    {
        $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']);

        $cache = $this->createMock(CacheInterface::class);

        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withQueryParams([
                'broker' => 'foo',
                'checksum' => $this->generateChecksum('attach', 'bar', '123456'),
                'token' => '123456'
            ]);

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $session->expects($this->once())->method('start')->id('start');
        $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123');

        $cache->expects($this->once())->method('get')
            ->with('SSO-foo-123456')
            ->willReturn(null);
        $cache->expects($this->once())->method('set')
            ->with('SSO-foo-123456', 'abc123')
            ->willReturn(false);

        $logger->expects($this->once())->method('error')
            ->with(
                "Failed to attach bearer token to session id due to cache issue",
                ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123']
            );

        $this->expectException(ServerException::class);
        $this->expectExceptionMessage("Failed to attach bearer token to session id");

        $server->attach($request);
    }
}


================================================
FILE: tests/unit/Server/BrokerSessionTest.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\Tests\SSO\Server;

use Jasny\HttpMessage\ServerRequest;
use Jasny\HttpMessage\Uri;
use Jasny\PHPUnit\CallbackMockTrait;
use Jasny\PHPUnit\SafeMocksTrait;
use Jasny\SSO\Server\BrokerException;
use Jasny\SSO\Server\Server;
use Jasny\SSO\Server\ServerException;
use Jasny\SSO\Server\SessionInterface;
use Jasny\Tests\SSO\TokenTrait;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;

/**
 * Test Server::startBrokerSession() and related methods.
 *
 * @covers \Jasny\SSO\Server\Server
 */
class BrokerSessionTest extends \Codeception\Test\Unit
{
    use TokenTrait;
    use CallbackMockTrait;
    use SafeMocksTrait;

    public function testSuccessfulStart()
    {
        $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']);

        $cache = $this->createMock(CacheInterface::class);

        $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123');
        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withHeader('Authorization', "Bearer $bearer");

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $cache->expects($this->once())->method('get')
            ->with('SSO-foo-123456')
            ->willReturn('abc123');

        $session->expects($this->once())->method('isActive')->willReturn(false);
        $session->expects($this->once())->method('resume')->with('abc123');

        $logger->expects($this->once())->method('debug')
            ->with(
                "Broker request with session",
                ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123']
            );

        $server->startBrokerSession($request);
    }

    public function testSessionAlreadyStarted()
    {
        $callback = $this->createCallbackMock($this->never());

        $cache = $this->createMock(CacheInterface::class);

        $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123');
        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withHeader('Authorization', "Bearer $bearer");

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $session->expects($this->once())->method('isActive')->willReturn(true);
        $session->expects($this->never())->method('start');

        $this->expectException(ServerException::class);
        $this->expectExceptionMessage("Session is already started");

        $server->startBrokerSession($request);
    }

    public function testMissingAuthorizationHeader()
    {
        $callback = $this->createCallbackMock($this->never());

        $cache = $this->createMock(CacheInterface::class);

        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"));

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $session->expects($this->once())->method('isActive')->willReturn(false);
        $session->expects($this->never())->method('start');

        $logger->expects($this->once())->method('warning')
            ->with("Broker didn't use bearer authentication: No 'Authorization' header");

        $this->expectException(BrokerException::class);
        $this->expectExceptionMessage("Broker didn't use bearer authentication");

        $server->startBrokerSession($request);
    }

    public function testNoBearerAuthorization()
    {
        $callback = $this->createCallbackMock($this->never());

        $cache = $this->createMock(CacheInterface::class);

        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withHeader("Authorization", "Basic dXNlcjpwYXNz");

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $session->expects($this->once())->method('isActive')->willReturn(false);
        $session->expects($this->never())->method('start');

        $logger->expects($this->once())->method('warning')
            ->with("Broker didn't use bearer authentication: Basic authorization used");

        $this->expectException(BrokerException::class);
        $this->expectExceptionMessage("Broker didn't use bearer authentication");

        $server->startBrokerSession($request);
    }

    public function testInvalidBearerToken()
    {
        $callback = $this->createCallbackMock($this->never());

        $cache = $this->createMock(CacheInterface::class);

        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withHeader('Authorization', "Bearer 000000");

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $session->expects($this->once())->method('isActive')->willReturn(false);
        $session->expects($this->never())->method('start');
        $session->expects($this->never())->method('resume');

        $logger->expects($this->once())->method('warning')
            ->with("Invalid bearer token", ['bearer' => '000000']);

        $this->expectException(BrokerException::class);
        $this->expectExceptionCode(403);
        $this->expectExceptionMessage("Invalid bearer token");

        $server->startBrokerSession($request);
    }

    public function testInvalidChecksum()
    {
        $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']);

        $cache = $this->createMock(CacheInterface::class);

        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withHeader('Authorization', "Bearer SSO-foo-123456-000000");

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $cache->expects($this->once())->method('get')
            ->with('SSO-foo-123456')
            ->willReturn('abc123');

        $session->expects($this->once())->method('isActive')->willReturn(false);
        $session->expects($this->never())->method('start');

        $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123');

        $logger->expects($this->once())->method('warning')
            ->with(
                "Invalid bearer checksum",
                [
                    'expected' => str_replace('SSO-foo-123456-', '', $bearer),
                    'received' => '000000',
                    'broker' => 'foo',
                    'token' => '123456',
                    'verification_code' => $this->getVerificationCode('foo', '123456', 'abc123')
                ]
            );

        $this->expectException(BrokerException::class);
        $this->expectExceptionCode(403);
        $this->expectExceptionMessage("Invalid bearer checksum");

        $server->startBrokerSession($request);
    }

    public function testUnattachedToken()
    {
        $callback = $this->createCallbackMock($this->any(), ['foo'], ['secret' => 'bar']);

        $cache = $this->createMock(CacheInterface::class);

        $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123');

        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withHeader('Authorization', "Bearer $bearer");

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $cache->expects($this->once())->method('get')
            ->with('SSO-foo-123456')
            ->willReturn(null);

        $session->expects($this->once())->method('isActive')->willReturn(false);
        $session->expects($this->never())->method('start');

        $logger->expects($this->once())->method('warning')
            ->with(
                "Bearer token isn't attached to a client session",
                ['broker' => 'foo', 'token' => '123456']
            );

        $this->expectException(BrokerException::class);
        $this->expectExceptionCode(403);
        $this->expectExceptionMessage("Bearer token isn't attached to a client session");

        $server->startBrokerSession($request);
    }

    public function testUnknownBroker()
    {
        $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], null);

        $cache = $this->createMock(CacheInterface::class);

        $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123');

        $request = (new ServerRequest())
            ->withUri(new Uri("https://server.example.com/attach.php"))
            ->withHeader('Authorization', "Bearer $bearer");

        $session = $this->createMock(SessionInterface::class);
        $logger = $this->createMock(LoggerInterface::class);

        $server = (new Server($callback, $cache))
            ->withSession($session)
            ->withLogger($logger);

        $cache->expects($this->once())->method('get')
            ->with('SSO-foo-123456')
            ->willReturn('abc123');

        $session->expects($this->once())->method('isActive')->willReturn(false);
        $session->expects($this->never())->method('start');

        $logger->expects($this->once())->method('warning')
            ->with("Unknown broker", ['broker' => 'foo', 'token' => '123456']);

        $this->expectException(BrokerException::class);
        $this->expectExceptionCode(403);
        $this->expectExceptionMessage("Broker is unknown or disabled");

        $server->startBrokerSession($request);
    }
}


================================================
FILE: tests/unit/TokenTrait.php
================================================
<?php

declare(strict_types=1);

namespace Jasny\Tests\SSO;

/**
 * Traits for server tests.
 */
trait TokenTrait
{
    protected function generateChecksum(string $command, string $secret, string $token): string
    {
        return base_convert(hash_hmac('sha256', $command . ':' . $token, $secret), 16, 36);
    }

    protected function getVerificationCode(string $brokerId, string $token, string $sessionId): string
    {
        return base_convert(hash('sha256', $brokerId . $token . $sessionId), 16, 36);
    }

    protected function getBearerToken(string $broker, string $secret, string $token, string $sessionId): string
    {
        $code = $this->getVerificationCode($broker, $token, $sessionId);

        return "SSO-{$broker}-{$token}-" . $this->generateChecksum("bearer:$code", $secret, $token);
    }
}


================================================
FILE: tests/unit.suite.yml
================================================
actor: UnitTester
modules:
    enabled:
        - \Helper\Unit
coverage:
    enabled: true
    include:
        - src/*
Download .txt
gitextract_z0pcsndb/

├── .gitattributes
├── .github/
│   └── workflows/
│       └── php.yml
├── .gitignore
├── .scrutinizer.yml
├── LICENSE
├── README.md
├── codeception.yml
├── composer.json
├── demo/
│   ├── ajax-broker/
│   │   ├── api.php
│   │   ├── app.js
│   │   ├── attach.php
│   │   ├── index.html
│   │   └── verify.php
│   ├── broker/
│   │   ├── error.php
│   │   ├── include/
│   │   │   ├── attach.php
│   │   │   └── functions.php
│   │   ├── index.php
│   │   ├── login.php
│   │   └── logout.php
│   └── server/
│       ├── api/
│       │   ├── info.php
│       │   ├── login.php
│       │   └── logout.php
│       ├── attach.php
│       └── include/
│           ├── config.php
│           └── start_broker_session.php
├── phpcs.xml
├── phpstan.neon
├── src/
│   ├── Broker/
│   │   ├── Broker.php
│   │   ├── Cookies.php
│   │   ├── Curl.php
│   │   ├── NotAttachedException.php
│   │   ├── RequestException.php
│   │   └── Session.php
│   └── Server/
│       ├── BrokerException.php
│       ├── ExceptionInterface.php
│       ├── GlobalSession.php
│       ├── Server.php
│       ├── ServerException.php
│       └── SessionInterface.php
└── tests/
    ├── _bootstrap.php
    ├── _output/
    │   └── .gitignore
    ├── _support/
    │   ├── DemoTester.php
    │   ├── Helper/
    │   │   ├── Demo.php
    │   │   └── Unit.php
    │   ├── PhpBuiltInServer.php
    │   └── UnitTester.php
    ├── demo/
    │   └── DemoCept.php
    ├── demo.suite.yml
    ├── unit/
    │   ├── Broker/
    │   │   ├── AttachTest.php
    │   │   └── RequestTest.php
    │   ├── Server/
    │   │   ├── AttachTest.php
    │   │   └── BrokerSessionTest.php
    │   └── TokenTrait.php
    └── unit.suite.yml
Download .txt
SYMBOL INDEX (138 symbols across 24 files)

FILE: demo/ajax-broker/app.js
  function attach (line 9) | function attach()
  function doApiRequest (line 40) | function doApiRequest(command, params, callback)
  function showError (line 61) | function showError(data)
  function showUserInfo (line 72) | function showUserInfo(info)

FILE: demo/broker/include/functions.php
  function redirect (line 8) | function redirect(string $url): void

FILE: src/Broker/Broker.php
  class Broker (line 15) | class Broker
    method __construct (line 71) | public function __construct(string $url, string $broker, string $secret)
    method withTokenIn (line 94) | public function withTokenIn(\ArrayAccess $handler): self
    method withCurl (line 105) | public function withCurl(Curl $curl): self
    method getCurl (line 113) | protected function getCurl(): Curl
    method getBrokerId (line 125) | public function getBrokerId(): string
    method initialize (line 133) | protected function initialize(): void
    method getToken (line 147) | protected function getToken(): ?string
    method getVerificationCode (line 157) | protected function getVerificationCode(): ?string
    method getCookieName (line 168) | protected function getCookieName(string $type): string
    method getBearerToken (line 180) | public function getBearerToken(): string
    method generateToken (line 196) | protected function generateToken(): void
    method clearToken (line 205) | public function clearToken(): void
    method isAttached (line 217) | public function isAttached(): bool
    method getAttachUrl (line 228) | public function getAttachUrl(array $params = []): string
    method verify (line 246) | public function verify(string $code): void
    method generateChecksum (line 266) | protected function generateChecksum(string $command): string
    method getRequestUrl (line 278) | protected function getRequestUrl(string $path, $params = ''): string
    method request (line 299) | public function request(string $method, string $path, $data = '')
    method handleResponse (line 322) | protected function handleResponse(int $httpCode, $ctHeader, string $body)

FILE: src/Broker/Cookies.php
  class Cookies (line 13) | class Cookies implements \ArrayAccess
    method __construct (line 27) | public function __construct(int $ttl = 3600, string $path = '', string...
    method offsetExists (line 38) | public function offsetExists(mixed $offset): bool
    method offsetGet (line 46) | public function offsetGet(mixed $offset): mixed
    method offsetSet (line 54) | public function offsetSet(mixed $offset, mixed $value): void
    method offsetUnset (line 68) | public function offsetUnset(mixed $offset): void

FILE: src/Broker/Curl.php
  class Curl (line 12) | class Curl
    method __construct (line 19) | public function __construct()
    method request (line 36) | public function request(string $method, string $url, array $headers, $...

FILE: src/Broker/NotAttachedException.php
  class NotAttachedException (line 10) | class NotAttachedException extends \RuntimeException

FILE: src/Broker/RequestException.php
  class RequestException (line 10) | class RequestException extends \RuntimeException

FILE: src/Broker/Session.php
  class Session (line 13) | class Session implements \ArrayAccess
    method offsetSet (line 18) | public function offsetSet($name, $value): void
    method offsetUnset (line 26) | public function offsetUnset($name): void
    method offsetGet (line 34) | public function offsetGet($name)
    method offsetExists (line 42) | public function offsetExists($name): bool

FILE: src/Server/BrokerException.php
  class BrokerException (line 11) | class BrokerException extends \RuntimeException implements ExceptionInte...

FILE: src/Server/ExceptionInterface.php
  type ExceptionInterface (line 7) | interface ExceptionInterface
    method getMessage (line 14) | public function getMessage();
    method getCode (line 21) | public function getCode();

FILE: src/Server/GlobalSession.php
  class GlobalSession (line 12) | class GlobalSession implements SessionInterface
    method __construct (line 25) | public function __construct(array $options = [])
    method getId (line 33) | public function getId(): string
    method start (line 41) | public function start(): void
    method resume (line 59) | public function resume(string $id): void
    method isActive (line 78) | public function isActive(): bool

FILE: src/Server/Server.php
  class Server (line 17) | class Server
    method __construct (line 50) | public function __construct(callable $getBrokerInfo, CacheInterface $c...
    method withLogger (line 64) | public function withLogger(LoggerInterface $logger): self
    method withSession (line 74) | public function withSession(SessionInterface $session): self
    method startBrokerSession (line 86) | public function startBrokerSession(?ServerRequestInterface $request = ...
    method getBearerToken (line 120) | protected function getBearerToken(?ServerRequestInterface $request = n...
    method parseBearer (line 143) | protected function parseBearer(string $bearer): array
    method getCacheKey (line 158) | protected function getCacheKey(string $brokerId, string $token): string
    method getBrokerSecret (line 169) | protected function getBrokerSecret(string $brokerId): ?string
    method getVerificationCode (line 177) | protected function getVerificationCode(string $brokerId, string $token...
    method generateChecksum (line 185) | protected function generateChecksum(string $command, string $brokerId,...
    method validateChecksum (line 202) | protected function validateChecksum(
    method validateDomain (line 224) | public function validateDomain(string $type, string $url, string $brok...
    method attach (line 245) | public function attach(?ServerRequestInterface $request = null): string
    method assertNotAttached (line 271) | protected function assertNotAttached(string $brokerId, string $token):...
    method processAttachRequest (line 294) | protected function processAttachRequest(?ServerRequestInterface $reque...
    method getQueryParam (line 323) | protected function getQueryParam(?ServerRequestInterface $request, str...
    method getRequiredQueryParam (line 337) | protected function getRequiredQueryParam(?ServerRequestInterface $requ...
    method getHeader (line 355) | protected function getHeader(?ServerRequestInterface $request, string ...

FILE: src/Server/ServerException.php
  class ServerException (line 11) | class ServerException extends \RuntimeException implements ExceptionInte...

FILE: src/Server/SessionInterface.php
  type SessionInterface (line 10) | interface SessionInterface
    method getId (line 15) | public function getId(): string;
    method start (line 23) | public function start(): void;
    method resume (line 31) | public function resume(string $id): void;
    method isActive (line 37) | public function isActive(): bool;

FILE: tests/_support/DemoTester.php
  class DemoTester (line 19) | class DemoTester extends \Codeception\Actor

FILE: tests/_support/Helper/Demo.php
  class Demo (line 13) | class Demo extends \Codeception\Module
    method _beforeSuite (line 24) | public function _beforeSuite($settings = [])
    method _afterSuite (line 54) | public function _afterSuite()
    method amOnBroker (line 68) | public function amOnBroker(int $nr): void

FILE: tests/_support/Helper/Unit.php
  class Unit (line 10) | class Unit extends \Codeception\Module

FILE: tests/_support/PhpBuiltInServer.php
  class PhpBuiltInServer (line 8) | class PhpBuiltInServer
    method __construct (line 33) | public function __construct(string $documentRoot, int $port, array $en...
    method run (line 47) | protected function run(string $documentRoot, array $env): void
    method getCommand (line 81) | protected function getCommand(string $documentRoot): string
    method isRemoteDebug (line 96) | protected function isRemoteDebug(): bool
    method testConnection (line 104) | protected function testConnection()
    method connect (line 120) | protected function connect(): bool
    method __destruct (line 130) | public function __destruct()
    method stop (line 138) | public function stop(): void
    method registerShutdown (line 157) | protected function registerShutdown(): void

FILE: tests/_support/UnitTester.php
  class UnitTester (line 19) | class UnitTester extends \Codeception\Actor

FILE: tests/unit/Broker/AttachTest.php
  class AttachTest (line 21) | class AttachTest extends TestCase
    method setUp (line 42) | public function setUp(): void
    method testUrlValidationInConstruct (line 52) | public function testUrlValidationInConstruct()
    method testBrokerIdValidationInConstruct (line 60) | public function testBrokerIdValidationInConstruct()
    method testGetBrokerId (line 68) | public function testGetBrokerId()
    method testGetAttachUrl (line 73) | public function testGetAttachUrl()
    method testGetAttachUrlWithParams (line 86) | public function testGetAttachUrlWithParams()
    method testVerify (line 103) | public function testVerify()
    method testVerifyIsIdempotent (line 117) | public function testVerifyIsIdempotent()
    method testVerifyIsImmutable (line 130) | public function testVerifyIsImmutable()
    method testClearToken (line 145) | public function testClearToken()

FILE: tests/unit/Broker/RequestTest.php
  class RequestTest (line 22) | class RequestTest extends TestCase
    method setUp (line 43) | public function setUp(): void
    method testGetBearerToken (line 56) | public function testGetBearerToken()
    method testGetBearerTokenWhenNotAttached (line 68) | public function testGetBearerTokenWhenNotAttached()
    method testGetRequest (line 82) | public function testGetRequest()
    method testPostRequest (line 101) | public function testPostRequest()
    method testNoContent (line 120) | public function testNoContent()
    method testBadRequest (line 139) | public function testBadRequest()
    method testInvalidContentType (line 159) | public function testInvalidContentType()
    method testInvalidJson (line 179) | public function testInvalidJson()

FILE: tests/unit/Server/AttachTest.php
  class AttachTest (line 25) | class AttachTest extends \Codeception\Test\Unit
    method testSuccessfulAttach (line 31) | public function testSuccessfulAttach()
    method missingQueryParameterProvider (line 83) | public function missingQueryParameterProvider()
    method testMissingQueryParameter (line 95) | public function testMissingQueryParameter(string $key)
    method domainProvider (line 132) | public function domainProvider()
    method testInvalidDomain (line 144) | public function testInvalidDomain(string $type, array $domains)
    method testInvalidChecksum (line 189) | public function testInvalidChecksum()
    method testUnknownBroker (line 226) | public function testUnknownBroker()
    method testAlreadyAttached (line 259) | public function testAlreadyAttached()
    method testAttachIsIdempotent (line 300) | public function testAttachIsIdempotent()
    method testCacheIssue (line 344) | public function testCacheIssue()

FILE: tests/unit/Server/BrokerSessionTest.php
  class BrokerSessionTest (line 24) | class BrokerSessionTest extends \Codeception\Test\Unit
    method testSuccessfulStart (line 30) | public function testSuccessfulStart()
    method testSessionAlreadyStarted (line 64) | public function testSessionAlreadyStarted()
    method testMissingAuthorizationHeader (line 91) | public function testMissingAuthorizationHeader()
    method testNoBearerAuthorization (line 119) | public function testNoBearerAuthorization()
    method testInvalidBearerToken (line 148) | public function testInvalidBearerToken()
    method testInvalidChecksum (line 179) | public function testInvalidChecksum()
    method testUnattachedToken (line 224) | public function testUnattachedToken()
    method testUnknownBroker (line 263) | public function testUnknownBroker()

FILE: tests/unit/TokenTrait.php
  type TokenTrait (line 10) | trait TokenTrait
    method generateChecksum (line 12) | protected function generateChecksum(string $command, string $secret, s...
    method getVerificationCode (line 17) | protected function getVerificationCode(string $brokerId, string $token...
    method getBearerToken (line 22) | protected function getBearerToken(string $broker, string $secret, stri...
Condensed preview — 54 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (118K chars).
[
  {
    "path": ".gitattributes",
    "chars": 271,
    "preview": "/demo export-ignore\n/tests export-ignore\n/.gitattributes export-ignore\n/.gitignore export-ignore\n/.scrutinizer.yml expor"
  },
  {
    "path": ".github/workflows/php.yml",
    "chars": 1081,
    "preview": "name: PHP\n\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    branches: [ master ]\n\njobs:\n  run:\n    runs-on: ubun"
  },
  {
    "path": ".gitignore",
    "chars": 200,
    "preview": ".DS_Store\nnbproject\n/vendor\ncomposer.lock\n\ntests/_output/*\n# Elastic Beanstalk Files\n.elasticbeanstalk/*\n!.elasticbeanst"
  },
  {
    "path": ".scrutinizer.yml",
    "chars": 561,
    "preview": "#language: php\nchecks:\n  php: true\nfilter:\n  excluded_paths:\n    - tests\nbuild:\n  nodes:\n    analysis:\n      environment"
  },
  {
    "path": "LICENSE",
    "chars": 1058,
    "preview": "Copyright (c) 2020 Arnold Daniels\n\nPermission is hereby granted, free of charge, to any person\nobtaining a copy of this "
  },
  {
    "path": "README.md",
    "chars": 11160,
    "preview": "![jasny-banner](https://user-images.githubusercontent.com/100821/62123924-4c501c80-b2c9-11e9-9677-2ebc21d9b713.png)\n\nSin"
  },
  {
    "path": "codeception.yml",
    "chars": 277,
    "preview": "actor: Tester\npaths:\n    tests: tests\n    log: tests/_output\n    data: tests/_data\n    support: tests/_support\n    envs:"
  },
  {
    "path": "composer.json",
    "chars": 1558,
    "preview": "{\n    \"name\": \"jasny/sso\",\n    \"description\": \"Simple Single Sign-On\",\n    \"keywords\": [\"sso\", \"auth\"],\n    \"license\": \""
  },
  {
    "path": "demo/ajax-broker/api.php",
    "chars": 691,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Broker\\Broker;\n\nrequire_once __DIR__ . '/../../vendor/autoload.php';\n\n// "
  },
  {
    "path": "demo/ajax-broker/app.js",
    "chars": 2567,
    "preview": "+function ($) {\n    // Init\n    attach();\n\n    /**\n     * Attach session.\n     * Will redirect to SSO server.\n     */\n  "
  },
  {
    "path": "demo/ajax-broker/attach.php",
    "chars": 536,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Broker\\Broker;\n\nrequire_once __DIR__ . '/../../vendor/autoload.php';\n\n// "
  },
  {
    "path": "demo/ajax-broker/index.html",
    "chars": 1759,
    "preview": "<!doctype html>\n<html>\n    <head>\n        <title>Single Sign-On Ajax demo</title>\n        <link rel=\"stylesheet\" href=\"h"
  },
  {
    "path": "demo/ajax-broker/verify.php",
    "chars": 458,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Broker\\Broker;\n\nrequire_once __DIR__ . '/../../vendor/autoload.php';\n\n// "
  },
  {
    "path": "demo/broker/error.php",
    "chars": 1577,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\n$brokerId = getenv('SSO_BROKER_ID');\n\n$error = isset($exception) ? $exception->getMessa"
  },
  {
    "path": "demo/broker/include/attach.php",
    "chars": 1060,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Broker\\Broker;\n\nrequire_once __DIR__ . '/functions.php';\n\n// Configure th"
  },
  {
    "path": "demo/broker/include/functions.php",
    "chars": 218,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * Redirect and through specified URL\n */\nfunction redirect(string $url): void\n{\n  "
  },
  {
    "path": "demo/broker/index.php",
    "chars": 1445,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Broker\\Broker;\n\nrequire_once __DIR__ . '/../../vendor/autoload.php';\n\n/**"
  },
  {
    "path": "demo/broker/login.php",
    "chars": 2101,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Broker\\Broker;\n\nrequire_once __DIR__ . '/../../vendor/autoload.php';\nrequ"
  },
  {
    "path": "demo/broker/logout.php",
    "chars": 370,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Broker\\Broker;\n\nrequire_once __DIR__ . '/../../vendor/autoload.php';\n\n/**"
  },
  {
    "path": "demo/server/api/info.php",
    "chars": 836,
    "preview": "<?php\n\n/**\n * API endpoint to get the user info.\n * If you don't have a method to authenticate users, consider [jasny/au"
  },
  {
    "path": "demo/server/api/login.php",
    "chars": 1239,
    "preview": "<?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"
  },
  {
    "path": "demo/server/api/logout.php",
    "chars": 634,
    "preview": "<?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 b"
  },
  {
    "path": "demo/server/attach.php",
    "chars": 2998,
    "preview": "<?php\n\n/**\n * An example script for attaching the broker token to a user session.\n */\n\ndeclare(strict_types=1);\n\nuse Jas"
  },
  {
    "path": "demo/server/include/config.php",
    "chars": 956,
    "preview": "<?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\n"
  },
  {
    "path": "demo/server/include/start_broker_session.php",
    "chars": 1195,
    "preview": "<?php\n\n/**\n * Create a new SSO Server instance.\n */\n\ndeclare(strict_types=1);\n\nuse Jasny\\SSO\\Server\\Server;\nuse Desarrol"
  },
  {
    "path": "phpcs.xml",
    "chars": 290,
    "preview": "<?xml version=\"1.0\"?>\n<ruleset name=\"Jasny\">\n    <description>The Jasny coding standard.</description>\n \n    <!-- Includ"
  },
  {
    "path": "phpstan.neon",
    "chars": 152,
    "preview": "parameters:\n    level: 7\n    paths:\n        - src\n    reportUnmatchedIgnoredErrors: false\nincludes:\n  \t- vendor/phpstan/"
  },
  {
    "path": "src/Broker/Broker.php",
    "chars": 9072,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Broker;\n\nuse Jasny\\Immutable;\n\n/**\n * Single sign-on broker.\n *\n * "
  },
  {
    "path": "src/Broker/Cookies.php",
    "chars": 1576,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Broker;\n\n/**\n * Use global $_COOKIE and setcookie() to persist the "
  },
  {
    "path": "src/Broker/Curl.php",
    "chars": 1934,
    "preview": "<?php /** @noinspection PhpComposerExtensionStubsInspection */\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Broker;\n\n/"
  },
  {
    "path": "src/Broker/NotAttachedException.php",
    "chars": 199,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Broker;\n\n/**\n * Exception thrown when a request is done while no se"
  },
  {
    "path": "src/Broker/RequestException.php",
    "chars": 146,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Broker;\n\n/**\n * SSO Request failed.\n */\nclass RequestException exte"
  },
  {
    "path": "src/Broker/Session.php",
    "chars": 752,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Broker;\n\n/**\n * Use global $_SESSION to persist the client token.\n "
  },
  {
    "path": "src/Server/BrokerException.php",
    "chars": 256,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Server;\n\n/**\n * Exception that's thrown if request from broker is i"
  },
  {
    "path": "src/Server/ExceptionInterface.php",
    "chars": 315,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Server;\n\ninterface ExceptionInterface\n{\n    /**\n     * Gets the Exc"
  },
  {
    "path": "src/Server/GlobalSession.php",
    "chars": 1864,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Server;\n\n/**\n * Interact with the global session using PHP's sessio"
  },
  {
    "path": "src/Server/Server.php",
    "chars": 11392,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Server;\n\nuse Jasny\\Immutable;\nuse Psr\\Http\\Message\\ServerRequestInt"
  },
  {
    "path": "src/Server/ServerException.php",
    "chars": 273,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Server;\n\n/**\n * Exception that's thrown if something unexpectedly w"
  },
  {
    "path": "src/Server/SessionInterface.php",
    "chars": 754,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\SSO\\Server;\n\n/**\n * Interface to start a session.\n */\ninterface Session"
  },
  {
    "path": "tests/_bootstrap.php",
    "chars": 94,
    "preview": "<?php\n\ndefine('ROOT_DIR', dirname(__DIR__));\n\nrequire_once ROOT_DIR . '/vendor/autoload.php';\n"
  },
  {
    "path": "tests/_output/.gitignore",
    "chars": 13,
    "preview": "*\n!.gitignore"
  },
  {
    "path": "tests/_support/DemoTester.php",
    "chars": 549,
    "preview": "<?php\n\n\n/**\n * Inherited Methods\n * @method void wantToTest($text)\n * @method void wantTo($text)\n * @method void execute"
  },
  {
    "path": "tests/_support/Helper/Demo.php",
    "chars": 1913,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Helper;\n\nuse Codeception\\Module\\PhpBrowser;\nuse PhpBuiltInServer;\n\n// here yo"
  },
  {
    "path": "tests/_support/Helper/Unit.php",
    "chars": 206,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Helper;\n\n// here you can define custom actions\n// all public methods declared"
  },
  {
    "path": "tests/_support/PhpBuiltInServer.php",
    "chars": 4261,
    "preview": "<?php\n\nuse Codeception\\Configuration;\n\n/**\n * Start/stop the PHP built-in web server on localhost\n */\nclass PhpBuiltInSe"
  },
  {
    "path": "tests/_support/UnitTester.php",
    "chars": 549,
    "preview": "<?php\n\n\n/**\n * Inherited Methods\n * @method void wantToTest($text)\n * @method void wantTo($text)\n * @method void execute"
  },
  {
    "path": "tests/demo/DemoCept.php",
    "chars": 1004,
    "preview": "<?php\n\n/** @var \\Codeception\\Scenario $scenario */\n$I = new DemoTester($scenario);\n$I->wantTo(\"login at broker 1 and see"
  },
  {
    "path": "tests/demo.suite.yml",
    "chars": 127,
    "preview": "actor: DemoTester\nmodules:\n    enabled:\n        - \\Helper\\Demo\n        - PhpBrowser:\n              url: 'http://localhos"
  },
  {
    "path": "tests/unit/Broker/AttachTest.php",
    "chars": 4650,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\Tests\\SSO\\Broker;\n\nuse Jasny\\PHPUnit\\ExpectWarningTrait;\nuse Jasny\\PHPU"
  },
  {
    "path": "tests/unit/Broker/RequestTest.php",
    "chars": 6188,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\Tests\\SSO\\Broker;\n\nuse Jasny\\PHPUnit\\ExpectWarningTrait;\nuse Jasny\\PHPU"
  },
  {
    "path": "tests/unit/Server/AttachTest.php",
    "chars": 13505,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\Tests\\SSO\\Server;\n\nuse Jasny\\HttpMessage\\ServerRequest;\nuse Jasny\\HttpM"
  },
  {
    "path": "tests/unit/Server/BrokerSessionTest.php",
    "chars": 10631,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\Tests\\SSO\\Server;\n\nuse Jasny\\HttpMessage\\ServerRequest;\nuse Jasny\\HttpM"
  },
  {
    "path": "tests/unit/TokenTrait.php",
    "chars": 820,
    "preview": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Jasny\\Tests\\SSO;\n\n/**\n * Traits for server tests.\n */\ntrait TokenTrait\n{\n    "
  },
  {
    "path": "tests/unit.suite.yml",
    "chars": 119,
    "preview": "actor: UnitTester\nmodules:\n    enabled:\n        - \\Helper\\Unit\ncoverage:\n    enabled: true\n    include:\n        - src/*"
  }
]

About this extraction

This page contains the full source code of the legalthings/sso GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 54 files (107.8 KB), approximately 28.4k tokens, and a symbol index with 138 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!