From 4bfcd50d4948c15dc2a1145a5c8895f253f7b11d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 11:54:57 +0000 Subject: [PATCH] Key OAuth2 sessions by cookie, call providers asynchronously and deprecate the stream-socket mode OAuth2 sessions were stored per client IP, so two players behind one NAT shared a login and ?user returned the previous visitor's profile. The browser is now identified by a random HttpOnly verifier_session cookie shared by /dwa and /ss14wa, so one browser's Discord and SS14 logins land under the same key (Civilizationbot's Civ14 verifier correlates them by that key), while unrelated browsers never collide. Idle sessions expire after 24 hours and a malformed cookie value gets a fresh id. The token exchange, profile and revocation calls used blocking cURL inside the event loop, which stalled the whole process (verifier, Discord gateway and HTTP server share one loop) for every provider round trip. The authenticator now takes an asynchronous HTTP client (ReactPHP Browser by default, 10 s timeout) and returns promises; a new AsyncEndpointInterface lets handleReact answer when the promise settles. The synchronous in-process adapter still serves login, logout and cached profile reads and answers 503 for anything that needs a provider call. A code can be exchanged once per login; the Discord revocation path is /oauth2/token/revoke as documented. init(null, true) (stream-socket mode) now emits a deprecation notice; the README explains its limits. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016WTP2S4qKEFT8a6a6yFEL4 --- README.md | 20 +- src/Discord/OAuth2Authenticator.php | 85 ++--- src/SS14/OAuth2Authenticator.php | 25 +- .../Interfaces/AsyncEndpointInterface.php | 23 ++ .../Endpoints/OAuth2Endpoint.php | 209 ++++++----- src/VerifierServer/OAuth2Authenticator.php | 353 ++++++++---------- src/VerifierServer/Server.php | 42 ++- src/VerifierServer/SessionCookie.php | 54 +++ tests/isolated/OAuth2SessionTest.php | 199 ++++++++++ 9 files changed, 639 insertions(+), 371 deletions(-) create mode 100644 src/VerifierServer/Endpoints/Interfaces/AsyncEndpointInterface.php create mode 100644 src/VerifierServer/SessionCookie.php create mode 100644 tests/isolated/OAuth2SessionTest.php diff --git a/README.md b/README.md index e87f4d5..ac21efb 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,10 @@ Verifier-Server is a web server designed to handle BYOND user verification. - Configurable via `.env` file. - Logger mode for detailed request and response logging. - Graceful shutdown support on all systems with ReactPHP. -- Graceful shutdown support on non-Windows systems with `pcntl` extension when not leveraging ReactPHP. +- Optional Discord (`/dwa`) and Space Station 14 (`/ss14wa`) OAuth2 login endpoints with + cookie-based browser sessions and non-blocking provider calls. +- A legacy stream-socket mode (`init(null, true)`) remains for compatibility but is deprecated: + it reads 1 KiB per request, cannot parse form bodies and does not serve the OAuth2 endpoints. ## Installation @@ -121,6 +124,21 @@ Status codes: Returns the full list as JSON. There is no read token; do not host the server on a public interface unless that is intended. +## OAuth2 web endpoints + +`/dwa` (Discord) and `/ss14wa` (Space Station 14) run the authorization-code +flow for a browser: `?login` redirects to the provider, the provider returns +to `?code=…&state=…`, `?user` returns the cached profile as JSON, `?remove` +revokes the token and `?logout` forgets the session. + +The browser is identified by the `verifier_session` cookie (random id, +HttpOnly, SameSite=Lax, 24 h idle expiry), which both endpoints share so one +browser's Discord and SS14 logins are stored under the same key: +`Server::getSessions()[][]`. Nothing is keyed by client +IP. Provider calls use ReactPHP's `Browser` with a 10 s timeout and never +block the event loop; the in-process `handleEndpoint()` adapter serves only +requests that need no provider round trip and answers 503 otherwise. + ## Running Tests To run the tests, execute the following command: diff --git a/src/Discord/OAuth2Authenticator.php b/src/Discord/OAuth2Authenticator.php index 41a03c1..67f28cc 100644 --- a/src/Discord/OAuth2Authenticator.php +++ b/src/Discord/OAuth2Authenticator.php @@ -8,8 +8,11 @@ namespace Discord; +use React\Promise\PromiseInterface; use VerifierServer\OAuth2Authenticator as __OAuth2Authenticator; +use function React\Promise\resolve; + class OAuth2Authenticator extends __OAuth2Authenticator { protected string $oidc_config = 'https://discord.com/.well-known/openid-configuration'; @@ -17,74 +20,56 @@ class OAuth2Authenticator extends __OAuth2Authenticator protected string $authorization_endpoint = '/oauth2/authorize'; protected string $token_endpoint = '/oauth2/token'; protected string $userinfo_endpoint = '/users/@me'; - protected string $revocation_endpoint = '/oauth2/token/remove'; + protected string $revocation_endpoint = '/oauth2/token/revoke'; protected string $connections_endpoint = '/users/@me/connections'; public function __construct( - $request, - protected array &$sessions, + callable $http, string $resolved_ip, string $web_address, int $http_port, - protected string $client_id, - protected string $client_secret, - protected string $endpoint_name = 'dwa', - protected string $scope = 'identify guilds connections' + string $client_id, + string $client_secret, + string $endpoint_name = 'dwa', + string $scope = 'identify guilds connections' ) { - parent::__construct( - $request, - $sessions, - $resolved_ip, - $web_address, - $http_port, - $client_id, - $client_secret, - $endpoint_name, - $scope - ); + parent::__construct($http, $resolved_ip, $web_address, $http_port, $client_id, $client_secret, $endpoint_name, $scope); } /** - * Processes a connection object and stores relevant OAuth data in the session. - * - * Properties of $connection: - * - id (string): The ID of the connection account. - * - name (string): The username of the connection account. - * - type (string): The service of the connection (e.g., "twitch", "youtube", "steam"). - * - revoked (boolean, optional): Whether the connection is revoked. - * - integrations (array, optional): An array of partial server integrations. - * - verified (boolean): Whether the connection is verified. - * - friend_sync (boolean): Whether friend sync is enabled for this connection. - * - show_activity (boolean): Whether activities related to this connection will be shown in presence updates. - * - visibility (integer): The visibility of this connection. + * Loads the user's linked accounts and records them in the session as + * `oauth__id` / `oauth__name` (plus `oauth_steam_url` for Steam). * - * Session keys set: - * - oauth__id: The ID of the connection account. - * - oauth__name: The username of the connection account. - * - oauth_steam_url (if type is "steam"): The Steam profile URL for the connection account. + * @return PromiseInterface */ - protected function getConnections(): ?object + protected function getConnections(array &$session): PromiseInterface { - if (!$connections = $this->apiRequest("{$this->issuer}{$this->connections_endpoint}")) { - return null; - } - foreach($connections as $__ => $connection) - { - if (isset($connection->type)) { - $this->sessions[$this->endpoint_name][$this->requesting_ip]["oauth_{$connection->type}_id"] = $connection->id; - $this->sessions[$this->endpoint_name][$this->requesting_ip]["oauth_{$connection->type}_name"] = $connection->name; - if ($connection->type == 'steam') $this->sessions[$this->endpoint_name][$this->requesting_ip]['oauth_steam_url'] = "https://steamcommunity.com/profiles/{$connection->id}/"; - } + if (empty($session['access_token'])) { + return resolve(null); } - return $connections; + return $this->apiRequest("{$this->issuer}{$this->connections_endpoint}", [], $session['access_token']) + ->then(static function (object $decoded) use (&$session): array { + $connections = (array) $decoded; + foreach ($connections as $connection) { + if (!is_object($connection) || !isset($connection->type, $connection->id)) continue; + $session["oauth_{$connection->type}_id"] = $connection->id; + $session["oauth_{$connection->type}_name"] = $connection->name ?? ''; + if ($connection->type === 'steam') { + $session['oauth_steam_url'] = "https://steamcommunity.com/profiles/{$connection->id}/"; + } + } + return $connections; + }); } - protected function getGuild($id): ?object + protected function getGuild(array $session, $id): ?object { - if (empty($this->user) || empty($this->user->guilds)) { + $user = $session['user'] ?? null; + if (empty($user) || empty($user->guilds)) { return null; } - return $this->user->guilds[array_search($id, array_column($this->user->guilds, 'id'))] ?? null; + $index = array_search($id, array_column($user->guilds, 'id')); + return $index === false ? null : ($user->guilds[$index] ?? null); } -} \ No newline at end of file +} diff --git a/src/SS14/OAuth2Authenticator.php b/src/SS14/OAuth2Authenticator.php index 283af5b..3be544c 100644 --- a/src/SS14/OAuth2Authenticator.php +++ b/src/SS14/OAuth2Authenticator.php @@ -20,26 +20,15 @@ class OAuth2Authenticator extends __OAuth2Authenticator protected string $revocation_endpoint = '/connect/revocation'; public function __construct( - $request, - protected array &$sessions, + callable $http, string $resolved_ip, string $web_address, int $http_port, - protected string $client_id, - protected string $client_secret, - protected string $endpoint_name = 'ss14wa', - protected string $scope = 'openid profile email' + string $client_id, + string $client_secret, + string $endpoint_name = 'ss14wa', + string $scope = 'openid profile email' ) { - parent::__construct( - $request, - $sessions, - $resolved_ip, - $web_address, - $http_port, - $client_id, - $client_secret, - $endpoint_name, - $scope - ); + parent::__construct($http, $resolved_ip, $web_address, $http_port, $client_id, $client_secret, $endpoint_name, $scope); } -} \ No newline at end of file +} diff --git a/src/VerifierServer/Endpoints/Interfaces/AsyncEndpointInterface.php b/src/VerifierServer/Endpoints/Interfaces/AsyncEndpointInterface.php new file mode 100644 index 0000000..f003ac1 --- /dev/null +++ b/src/VerifierServer/Endpoints/Interfaces/AsyncEndpointInterface.php @@ -0,0 +1,23 @@ + + */ + public function handleAsync(string $method, ServerRequestInterface $request): PromiseInterface; +} diff --git a/src/VerifierServer/Endpoints/OAuth2Endpoint.php b/src/VerifierServer/Endpoints/OAuth2Endpoint.php index 2c3794a..ce430d8 100644 --- a/src/VerifierServer/Endpoints/OAuth2Endpoint.php +++ b/src/VerifierServer/Endpoints/OAuth2Endpoint.php @@ -8,15 +8,36 @@ namespace VerifierServer\Endpoints; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use React\EventLoop\Loop; +use React\EventLoop\LoopInterface; +use React\Http\Browser; use React\Http\Message\Response; +use React\Promise\PromiseInterface; use VerifierServer\Endpoint; +use VerifierServer\Endpoints\Interfaces\AsyncEndpointInterface; use VerifierServer\OAuth2Authenticator; +use VerifierServer\SessionCookie; -class OAuth2Endpoint extends Endpoint +use function React\Promise\resolve; + +/** + * Browser-facing OAuth2 login endpoint (`/dwa`, `/ss14wa`). + * + * Sessions are keyed by the `verifier_session` cookie, which every OAuth2 + * endpoint on the server shares, and are stored as + * `$sessions[][]`. Provider calls are + * asynchronous; the synchronous handle() only serves requests that need no + * provider round trip. + */ +class OAuth2Endpoint extends Endpoint implements AsyncEndpointInterface { protected string $auth = OAuth2Authenticator::class; - protected array $cache = []; + protected array $allowed_methods = ['GET', 'POST']; + protected ?OAuth2Authenticator $authenticator = null; + /** @var callable|null */ + protected $http; public function __construct( protected array &$sessions, @@ -25,51 +46,82 @@ public function __construct( protected int $http_port, protected string $client_id, protected string $client_secret, - ){} + ?callable $http = null, + protected ?LoopInterface $loop = null + ) { + $this->http = $http; + } - /** - * @param string $method - * @param ServerRequestInterface $request - * @param int|string &$response - * @param array &$headers - * @param string &$body - */ - public function handle( - string $method, - $request, - int|string &$response, - array &$headers, - string &$body - ): void + protected function authenticator(): OAuth2Authenticator { - switch ($method) { - case 'GET': - $this->get($request, $response, $headers, $body); - break; - case 'POST': - $this->post($request, $response, $headers, $body); - break; - case 'HEAD': - case 'PUT': - case 'DELETE': - case 'PATCH': - case 'OPTIONS': - case 'CONNECT': - default: - $response = Response::STATUS_METHOD_NOT_ALLOWED; - $headers = ['Content-Type' => 'text/plain']; - $body = 'Method Not Allowed'; - break; + return $this->authenticator ??= new ($this->auth)( + $this->http ?? $this->browserClient(), + $this->resolved_ip, + $this->web_address, + $this->http_port, + $this->client_id, + $this->client_secret + ); + } + + /** Default provider client: a bounded ReactPHP Browser on the server's loop. */ + protected function browserClient(): callable + { + $browser = (new Browser(null, $this->loop ?? Loop::get())) + ->withTimeout(10) + ->withFollowRedirects(false) + ->withRejectErrorResponse(false); + return static fn(string $method, string $url, array $headers, string $body): PromiseInterface + => $browser->request($method, $url, $headers, $body); + } + + public function handleAsync(string $method, ServerRequestInterface $request): PromiseInterface + { + if (!in_array($method, $this->allowed_methods, true)) { + return resolve(new Response(Response::STATUS_METHOD_NOT_ALLOWED, ['Content-Type' => 'text/plain'], 'Method Not Allowed')); } + $params = $request->getQueryParams(); + if (!$params) { + return resolve(new Response(Response::STATUS_BAD_REQUEST, ['Content-Type' => 'text/plain'], 'Bad Request')); + } + + $auth = $this->authenticator(); + $name = $auth->getEndpointName(); + [$id, $isNew] = SessionCookie::resolve($request); + $this->sessions[$name] ??= []; + SessionCookie::prune($this->sessions[$name]); + $session = &$this->sessions[$name][$id]; + $session ??= []; + $session['touched'] = time(); + + if (isset($params['code'], $params['state']) && is_string($params['code']) && is_string($params['state'])) { + $result = $auth->exchangeCode($request, $session, $params['code'], $params['state']); + } elseif (isset($params['login'])) { + $result = resolve($auth->login($request, $session)); + } elseif (isset($params['logout'])) { + $result = resolve($auth->logout($session)); + $session['touched'] = time(); + } elseif (isset($params['remove'])) { + $result = $auth->isAuthed($session) + ? $auth->revoke($session) + : resolve(new Response(Response::STATUS_UNAUTHORIZED, ['Content-Type' => 'text/plain'], 'Unauthorized')); + } elseif (isset($params['user'])) { + $result = $auth->fetchUser($session)->then(static fn(?object $user): ResponseInterface + => new Response(Response::STATUS_OK, ['Content-Type' => 'application/json'], json_encode($user ?? []))); + } else { + $result = resolve(new Response(Response::STATUS_BAD_REQUEST, ['Content-Type' => 'text/plain'], 'Bad Request')); + } + + return $result->then(static fn(ResponseInterface $response): ResponseInterface + => $isNew ? SessionCookie::attach($response, $id) : $response); } /** - * @param ServerRequestInterface|string $request - * @param int|string &$response - * @param array &$headers - * @param string &$body + * Synchronous adapter for in-process callers. Requests that need a + * provider round trip cannot complete here and answer 503. */ - private function get( + public function handle( + string $method, $request, int|string &$response, array &$headers, @@ -82,73 +134,30 @@ private function get( $body = 'Method Not Allowed'; return; } - if (!$params = $request->getQueryParams()) { - $response = Response::STATUS_BAD_REQUEST; - $headers = ['Content-Type' => 'text/plain']; - $body = 'Bad Request'; - return; - } - - $requesting_ip = $request->getServerParams()['REMOTE_ADDR'] ?? '127.0.0.1'; // For session management, will be deprecated in favor of a more robust solution - $OAA = - &$this->cache[$requesting_ip]['OAuth2Authenticator'] ?? - $this->cache[$requesting_ip]['OAuth2Authenticator'] = new ($this->auth)( - $request, - $this->sessions, - $this->resolved_ip, - $this->web_address, - $this->http_port, - $this->client_id, - $this->client_secret - ); - /** @var OAuth2Authenticator $OAA */ - - if (isset($params['code'], $params['state'])) { - if ($OAA->getToken($response, $headers, $body, $params['code'], $params['state'])) { - $OAA->getUser(); - } - return; - } - if (isset($params['login'])) { - $OAA->login($response, $headers, $body); - return; - } - if (isset($params['logout'])) { - $OAA->logout($response, $headers, $body); - return; - } - if (isset($params['remove']) && $OAA->isAuthed()) { - $OAA->removeToken($response, $headers, $body); - return; + $settled = null; + $this->handleAsync($method, $request)->then( + static function (ResponseInterface $result) use (&$settled): void { $settled = $result; }, + static function (\Throwable $e) use (&$settled): void { $settled = $e; } + ); + if ($settled instanceof \Throwable) { + throw $settled; } - if (isset($params['user'])) { - $response = Response::STATUS_OK; - $headers = ['Content-Type' => 'application/json']; - $body = json_encode($OAA->getUser() ?? []); + if (!$settled instanceof ResponseInterface) { + $response = Response::STATUS_SERVICE_UNAVAILABLE; + $headers = ['Content-Type' => 'text/plain']; + $body = 'This request needs the asynchronous server.'; return; } - } - - /** - * @param ServerRequestInterface|string $request - * @param string|int &$response - * @param array &$headers - * @param string &$body - */ - private function post( - $request, - int|string &$response, - array &$headers, - string &$body - ): void - { - $this->get($request, $response, $headers, $body); + $response = $settled->getStatusCode(); + $headers = []; + foreach ($settled->getHeaders() as $key => $values) $headers[$key] = implode(', ', $values); + $body = (string) $settled->getBody(); } public function __serialize(): array { $data = get_object_vars($this); - unset($data['client_id'], $data['client_secret']); + unset($data['client_id'], $data['client_secret'], $data['http'], $data['loop'], $data['authenticator']); return $data; } @@ -162,7 +171,7 @@ public function __unserialize(array $data): void public function __debugInfo(): array { $debugInfo = get_object_vars($this); - unset($debugInfo['client_id'], $debugInfo['client_secret']); + unset($debugInfo['client_id'], $debugInfo['client_secret'], $debugInfo['http']); return $debugInfo; } } diff --git a/src/VerifierServer/OAuth2Authenticator.php b/src/VerifierServer/OAuth2Authenticator.php index 57dc69a..6501499 100644 --- a/src/VerifierServer/OAuth2Authenticator.php +++ b/src/VerifierServer/OAuth2Authenticator.php @@ -8,9 +8,22 @@ namespace VerifierServer; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use React\Http\Message\Response; +use React\Promise\PromiseInterface; +use function React\Promise\reject; +use function React\Promise\resolve; + +/** + * Authorization-code flow against an OAuth2 provider. + * + * One instance serves every browser; the per-browser state (CSRF state, + * access token, user profile) lives in the `$session` array each method + * receives by reference. Provider requests go through an injected + * asynchronous HTTP client, so nothing here blocks the event loop. + */ class OAuth2Authenticator { protected string $oidc_config; @@ -18,248 +31,202 @@ class OAuth2Authenticator protected string $authorization_endpoint; protected string $token_endpoint; protected string $userinfo_endpoint; - //protected string $end_session_endpoint // NYI - //protected string $check_session_iframe // NYI protected string $revocation_endpoint; - //protected string $introspection_endpoint // NYI - //protected string $device_authorization_endpoint // NYI - protected string $default_redirect; - protected string $redirect_home; - protected array $allowed_uri = []; - - protected string $state; - protected ?string $access_token = null; - protected ?object $user = null; - - protected string $requesting_ip; // For session management, will be deprecated in favor of a more robust solution - + /** @var callable(string $method, string $url, array $headers, string $body): PromiseInterface */ + protected $http; /** - * OAuth2Authenticator constructor. - * - * Initializes the OAuth2 authentication process by setting up session data, - * request parameters, allowed URIs, and other necessary configurations. - * - * @param ServerRequestInterface $request - * @param array &$sessions - * @param string $resolved_ip - * @param string $web_address - * @param int $http_port - * @param string $client_id - * @param string $client_secret - * @param string $endpoint_name - * @param string $scope - * - * @throws \RuntimeException If the provided request is not an instance of ServerRequestInterface. + * @param callable $http Asynchronous HTTP client, see the property above. + * @param string $resolved_ip The server's public IP, accepted as an alternative redirect host. + * @param string $web_address The public host name the provider redirects back to. + * @param int $http_port The public port. + * @param string $endpoint_name Path of the endpoint, without slash; also the session namespace. */ public function __construct( - $request, - protected array &$sessions, - string $resolved_ip, - string $web_address, - int $http_port, + callable $http, + protected string $resolved_ip, + protected string $web_address, + protected int $http_port, protected string $client_id, protected string $client_secret, protected string $endpoint_name, protected string $scope ) { - if (! $request instanceof ServerRequestInterface) { - throw new \RuntimeException('String requests are not supported.'); - } - $this->requesting_ip = $request->getServerParams()['REMOTE_ADDR'] ?? '127.0.0.1'; - $scheme = 'http'; //$request->getUri()->getScheme(); // TLS not supported - if (($host = $request->getUri()->getHost()) === '127.0.0.1') $host = $resolved_ip; // Should only happen when testing on localhost - $this->default_redirect = "$scheme://$host:$http_port" . explode('?', $request->getUri()->getPath())[0]; - - $this->redirect_home = "$scheme://$web_address:$http_port"; - $this->allowed_uri[] = $this->redirect_home . "/{$this->endpoint_name}"; // must be first in array for redirect_uri check - $this->allowed_uri[] = $this->redirect_home; - if ($resolved_ip) { - $this->allowed_uri[] = "$scheme://$resolved_ip:$http_port/"; - $this->allowed_uri[] = "$scheme://$resolved_ip:$http_port/{$this->endpoint_name}"; - } - - $this->state = isset($this->sessions[$this->endpoint_name][$this->requesting_ip], $this->sessions[$this->endpoint_name][$this->requesting_ip]['state']) - ? $this->sessions[$this->endpoint_name][$this->requesting_ip]['state'] - : $this->sessions[$this->endpoint_name][$this->requesting_ip]['state'] = bin2hex(random_bytes(16)); // unguessable CSRF state - - if (isset($this->sessions[$this->endpoint_name][$this->requesting_ip]['access_token'])) { - $this->access_token = $this->sessions[$this->endpoint_name][$this->requesting_ip]['access_token']; - $this->getUser(); - } + $this->http = $http; } - protected function apiRequest( - string $url, - array $post = [], - bool $associative = false - ): object + public function getEndpointName(): string { - $headers = ['Accept: application/json', 'Content-Type: application/x-www-form-urlencoded']; - if (isset($this->access_token)) $headers[] = 'Authorization: Bearer ' . $this->access_token; - - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - if ($post) { - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post)); - } + return $this->endpoint_name; + } - $response = curl_exec($ch) - ?: throw new \RuntimeException('cURL error: ' . curl_error($ch)); - - $decoded = json_decode($response, $associative); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new \RuntimeException('JSON decode error: ' . json_last_error_msg()); - } - return $decoded; + /** Where the browser lands after login or logout. TLS is terminated elsewhere, if at all. */ + protected function redirectHome(): string + { + return "http://{$this->web_address}:{$this->http_port}"; } - public function login( - int|string &$response, - array &$headers, - string &$body, - ?string $redirect_uri = null, - ?string $scope = null - ): void + /** Redirect URIs the provider may send the browser back to; the first is canonical. */ + protected function allowedRedirects(): array { - if (! isset($this->issuer, $this->authorization_endpoint)) { - $response = Response::STATUS_BAD_REQUEST; - $headers = ['Content-Type' => 'text/plain']; - $body = 'Method not supported.'; - return; + $allowed = [$this->redirectHome() . "/{$this->endpoint_name}", $this->redirectHome()]; + if ($this->resolved_ip !== '') { + $allowed[] = "http://{$this->resolved_ip}:{$this->http_port}/"; + $allowed[] = "http://{$this->resolved_ip}:{$this->http_port}/{$this->endpoint_name}"; } + return $allowed; + } - if (!in_array($redirect_uri = $redirect_uri ?? $this->default_redirect, $this->allowed_uri)) { - $response = Response::STATUS_FOUND; - $headers = ['Location' => "{$this->endpoint_name}/{$this->allowed_uri[0]}?login"]; - $body = ''; - return; - } + /** The redirect URI matching the host the browser used, or the canonical one. */ + protected function redirectFor(ServerRequestInterface $request): string + { + $host = $request->getUri()->getHost(); + if ($host === '127.0.0.1' || $host === 'localhost') $host = $this->resolved_ip ?: $host; + $candidate = "http://$host:{$this->http_port}" . explode('?', $request->getUri()->getPath())[0]; + return in_array($candidate, $this->allowedRedirects(), true) ? $candidate : $this->allowedRedirects()[0]; + } - $response = Response::STATUS_FOUND; - $headers = ['Location' => "{$this->issuer}{$this->authorization_endpoint}?" - . http_build_query([ - 'client_id' => $this->client_id, - 'response_type' => 'code', - 'scope' => $scope ?? $this->scope, - 'state' => $this->state, - 'redirect_uri' => $redirect_uri ?? $this->default_redirect, - ])]; - $body = ''; + private function text(int $status, string $body): Response + { + return new Response($status, ['Content-Type' => 'text/plain'], $body); } - public function logout( - int|string &$response, - array &$headers, - string &$body - ): void + private function redirect(string $location): Response { - unset($this->sessions[$this->endpoint_name][$this->requesting_ip]); - $response = Response::STATUS_FOUND; - $headers = ['Location' => ($this->redirect_home ?? $this->default_redirect)]; - $body = ''; + return new Response(Response::STATUS_FOUND, ['Location' => $location], ''); } - public function removeToken( - int|string &$response, - array &$headers, - string &$body - ): void + /** Sends the browser to the provider's consent page with a fresh, unguessable state. */ + public function login(ServerRequestInterface $request, array &$session, ?string $scope = null): ResponseInterface { - if (! isset($this->issuer, $this->revocation_endpoint)) { - $response = Response::STATUS_BAD_REQUEST; - $headers = ['Content-Type' => 'text/plain']; - $body = 'Method not supported.'; - return; + if (!isset($this->issuer, $this->authorization_endpoint)) { + return $this->text(Response::STATUS_BAD_REQUEST, 'Method not supported.'); } + $session['state'] = bin2hex(random_bytes(16)); + return $this->redirect("{$this->issuer}{$this->authorization_endpoint}?" . http_build_query([ + 'client_id' => $this->client_id, + 'response_type' => 'code', + 'scope' => $scope ?? $this->scope, + 'state' => $session['state'], + 'redirect_uri' => $this->redirectFor($request), + ])); + } - if (isset($this->access_token)) $this->apiRequest( - $this->issuer . $this->revocation_endpoint, - [ - 'client_id' => $this->client_id, - 'client_secret' => $this->client_secret, - 'access_token' => $this->access_token - ] - ); - unset($this->sessions[$this->endpoint_name][$this->requesting_ip]['access_token']); + /** Forgets everything about this browser. */ + public function logout(array &$session): ResponseInterface + { + $session = []; + return $this->redirect($this->redirectHome()); } - public function getToken( - int|string &$response, - array &$headers, - string &$body, - string $code, - string $state, - string $redirect_uri = '' - ): ?string + /** + * Exchanges the provider's code for an access token, then loads the profile. + * + * @return PromiseInterface + */ + public function exchangeCode(ServerRequestInterface $request, array &$session, string $code, string $state): PromiseInterface { - if (!empty($this->access_token)) { - $response = Response::STATUS_FOUND; - $headers = ['Location' => $this->redirect_home]; - $body = ''; - return $this->access_token; + if (!empty($session['access_token'])) { + return resolve($this->redirect($this->redirectHome())); } - if (! isset($this->issuer, $this->token_endpoint)) { - $response = Response::STATUS_BAD_REQUEST; - $headers = ['Content-Type' => 'text/plain']; - $body = 'Method not supported.'; - return null; + if (!isset($this->issuer, $this->token_endpoint)) { + return resolve($this->text(Response::STATUS_BAD_REQUEST, 'Method not supported.')); } - - if ($state !== $this->state) { - $response = Response::STATUS_BAD_REQUEST; - $headers = ['Content-Type' => 'text/plain']; - $body = 'Invalid state.'; - return null; + if (!isset($session['state']) || !hash_equals($session['state'], $state)) { + return resolve($this->text(Response::STATUS_BAD_REQUEST, 'Invalid state.')); } + unset($session['state']); // one exchange per login + return $this->apiRequest("{$this->issuer}{$this->token_endpoint}", [ + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $this->redirectFor($request), + ])->then(function (object $token) use (&$session): PromiseInterface { + if (isset($token->error) || !isset($token->access_token) || !is_string($token->access_token)) { + return resolve($this->text(Response::STATUS_BAD_REQUEST, 'Error: ' . ($token->error ?? 'no access token'))); + } + $session['access_token'] = $token->access_token; + return $this->fetchUser($session)->then(fn() => $this->redirect($this->redirectHome())); + }); + } - $api_response = $this->apiRequest( - "{$this->issuer}{$this->token_endpoint}", - [ - 'client_id' => $this->client_id, - 'client_secret' => $this->client_secret, - 'grant_type' => 'authorization_code', - 'code' => $code, - 'redirect_uri' => $redirect_uri ?: $this->default_redirect, - ] - ); - - if (isset($api_response->error)) { - $response = Response::STATUS_BAD_REQUEST; - $headers = ['Content-Type' => 'text/plain']; - $body = 'Error: ' . $api_response->error; - return null; + /** + * The provider's profile for this session, fetched once and then cached in the session. + * + * @return PromiseInterface + */ + public function fetchUser(array &$session): PromiseInterface + { + if (!empty($session['user'])) { + return resolve($session['user']); } - - $response = Response::STATUS_FOUND; - $headers = ['Location' => $this->redirect_home]; - $body = ''; - return $this->sessions[$this->endpoint_name][$this->requesting_ip]['access_token'] = $this->access_token = $api_response->access_token; + if (!isset($this->issuer, $this->userinfo_endpoint) || empty($session['access_token'])) { + return resolve(null); + } + return $this->apiRequest("{$this->issuer}{$this->userinfo_endpoint}", [], $session['access_token']) + ->then(function (object $user) use (&$session): object { + return $session['user'] = $user; + }); } - public function getUser(): ?object + /** + * Revokes the access token at the provider and drops it from the session. + * + * @return PromiseInterface + */ + public function revoke(array &$session): PromiseInterface { - if (!empty($this->user)) { - return $this->sessions[$this->endpoint_name][$this->requesting_ip]['user'] = $this->user; + if (!isset($this->issuer, $this->revocation_endpoint)) { + return resolve($this->text(Response::STATUS_BAD_REQUEST, 'Method not supported.')); } - if (! isset($this->issuer, $this->userinfo_endpoint, $this->access_token)) { - return null; + if (empty($session['access_token'])) { + return resolve($this->redirect($this->redirectHome())); } - return $this->sessions[$this->endpoint_name][$this->requesting_ip]['user'] = $this->user = $this->apiRequest("{$this->issuer}{$this->userinfo_endpoint}"); + $token = $session['access_token']; + unset($session['access_token'], $session['user']); + return $this->apiRequest($this->issuer . $this->revocation_endpoint, [ + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'access_token' => $token, + 'token' => $token, + ])->then(fn() => $this->redirect($this->redirectHome())); } - public function isAuthed(): bool + public function isAuthed(array $session): bool { - return isset($this->user); + return !empty($session['user']); + } + + /** + * One JSON request to the provider. + * + * @return PromiseInterface + */ + protected function apiRequest(string $url, array $post = [], ?string $bearer = null): PromiseInterface + { + $headers = ['Accept' => 'application/json']; + if ($bearer !== null) $headers['Authorization'] = 'Bearer ' . $bearer; + if ($post) $headers['Content-Type'] = 'application/x-www-form-urlencoded'; + try { + $promise = ($this->http)($post ? 'POST' : 'GET', $url, $headers, $post ? http_build_query($post) : ''); + } catch (\Throwable $e) { + return reject($e); + } + return $promise->then(static function (ResponseInterface $response): object { + $decoded = json_decode((string) $response->getBody(), false); + if (!is_object($decoded)) { + throw new \RuntimeException('Provider returned HTTP ' . $response->getStatusCode() . ' without a JSON object'); + } + return $decoded; + }); } public function __debugInfo(): array { $debugInfo = get_object_vars($this); - unset($debugInfo['client_id'], $debugInfo['client_secret']); + unset($debugInfo['client_id'], $debugInfo['client_secret'], $debugInfo['http']); return $debugInfo; } -} \ No newline at end of file +} diff --git a/src/VerifierServer/Server.php b/src/VerifierServer/Server.php index 94a4d8f..bd05c1c 100644 --- a/src/VerifierServer/Server.php +++ b/src/VerifierServer/Server.php @@ -15,7 +15,9 @@ use React\Http\Message\Response; use React\Socket\SocketServer; use React\Http\Message\ServerRequest; +use React\Promise\PromiseInterface; use SS14\Endpoints\OAuth2Endpoint as SS14OAuth2Endpoint; +use VerifierServer\Endpoints\Interfaces\AsyncEndpointInterface; use VerifierServer\Endpoints\Interfaces\EndpointInterface; use VerifierServer\Endpoints\FaviconEndpoint; use VerifierServer\Endpoints\SS14VerifiedEndpoint; @@ -107,9 +109,10 @@ class Server { protected string $resolved_ip; /** - * The IP sessions. - * - * @var array IP sessions. + * OAuth2 browser sessions, `[endpoint name => [session cookie id => session]]`. + * The property keeps its historical name; sessions are keyed by cookie, not by IP. + * + * @var array> */ protected array $ip_sessions = []; @@ -141,6 +144,9 @@ protected function afterConstruct(): void public function init(?LoopInterface $loop = null, bool $stream_socket_server = false): void { if ($this->running) return; + if ($stream_socket_server) { + @trigger_error('The stream-socket server mode is deprecated: it reads 1 KiB per request, cannot parse form bodies and does not serve asynchronous endpoints. Use the ReactPHP server.', E_USER_DEPRECATED); + } $this->initialized = true; ($stream_socket_server) ? $this->__initStreamSocketServer() @@ -346,13 +352,23 @@ public function getEndpoint(string $uri): ?EndpointInterface * * @param ServerRequestInterface $client The incoming client request. * - * @return ResponseInterface The generated HTTP response. + * @return ResponseInterface|PromiseInterface The generated HTTP response. */ - private function handleReact(ServerRequestInterface $request): ResponseInterface + private function handleReact(ServerRequestInterface $request): ResponseInterface|PromiseInterface { $method = $request->getMethod(); $uri = $request->getUri()->getPath(); + $endpoint = $this->getEndpoint($uri); + if ($endpoint instanceof AsyncEndpointInterface && !in_array($method, self::DEFAULT_METHODS, true)) { + try { + $promise = $endpoint->handleAsync($method, $request); + } catch (Throwable $e) { + return $this->internalError($e); + } + return $promise->then(null, fn(Throwable $e): ResponseInterface => $this->internalError($e)); + } + // Defaults $response = Response::STATUS_NOT_FOUND; $headers = ['Content-Type' => 'text/plain']; @@ -362,10 +378,7 @@ private function handleReact(ServerRequestInterface $request): ResponseInterface $this->route($uri, $method, $request, $response, $headers, $body); } catch (Throwable $e) { // A failing request must not take the listener down with it. - $this->logError($e, false); - $response = Response::STATUS_INTERNAL_SERVER_ERROR; - $headers = ['Content-Type' => 'text/plain']; - $body = 'Internal Server Error'; + return $this->internalError($e); } return new Response( @@ -375,6 +388,13 @@ private function handleReact(ServerRequestInterface $request): ResponseInterface ); } + /** Logs a request failure and answers 500 without touching the listener. */ + private function internalError(Throwable $e): ResponseInterface + { + $this->logError($e, false); + return new Response(Response::STATUS_INTERNAL_SERVER_ERROR, ['Content-Type' => 'text/plain'], 'Internal Server Error'); + } + /** * Handles an incoming resource request from a client and generates appropriate responses. * @@ -628,6 +648,8 @@ public function setSS14OAuth2Endpoint( $this->port, $client_id, $client_secret, + null, + $this->loop ?? null ); } @@ -643,6 +665,8 @@ public function setDiscordOAuth2Endpoint( $this->port, $client_id, $client_secret, + null, + $this->loop ?? null ); } diff --git a/src/VerifierServer/SessionCookie.php b/src/VerifierServer/SessionCookie.php new file mode 100644 index 0000000..d01b212 --- /dev/null +++ b/src/VerifierServer/SessionCookie.php @@ -0,0 +1,54 @@ +getCookieParams()[self::NAME] ?? null; + if (is_string($id) && preg_match(self::ID_PATTERN, $id) === 1) { + return [$id, false]; + } + return [bin2hex(random_bytes(16)), true]; + } + + /** Drops every entry of `$sessions` that has not been touched within TTL. */ + public static function prune(array &$sessions, ?int $now = null): void + { + $now = $now ?? time(); + foreach ($sessions as $id => $session) { + if (!is_array($session) || ($session['touched'] ?? 0) < $now - self::TTL) { + unset($sessions[$id]); + } + } + } + + public static function attach(ResponseInterface $response, string $id): ResponseInterface + { + return $response->withAddedHeader( + 'Set-Cookie', + sprintf('%s=%s; Path=/; Max-Age=%d; HttpOnly; SameSite=Lax', self::NAME, $id, self::TTL) + ); + } +} diff --git a/tests/isolated/OAuth2SessionTest.php b/tests/isolated/OAuth2SessionTest.php new file mode 100644 index 0000000..7e44c46 --- /dev/null +++ b/tests/isolated/OAuth2SessionTest.php @@ -0,0 +1,199 @@ +calls[] = [$method, $url, $headers, $body]; + $path = parse_url($url, PHP_URL_PATH); + $response = new Response(200, ['Content-Type' => 'application/json'], json_encode($this->answers[$path] ?? ['error' => "no answer for $path"])); + if (!$this->defer) return resolve($response); + $deferred = new Deferred(); + $this->deferred[] = [$deferred, $response]; + return $deferred->promise(); + } + public function settleAll(): void + { + while ($pair = array_shift($this->deferred)) $pair[0]->resolve($pair[1]); + } +} + +final class OAuth2SessionTest extends TestCase +{ + private array $sessions = []; + + private function discord(FakeProvider $provider): DiscordOAuth2Endpoint + { + return new DiscordOAuth2Endpoint($this->sessions, '203.0.113.5', 'verifier.example', 8080, 'client-id', 'client-secret', $provider); + } + + private function request(string $query, ?string $cookie = null, string $method = 'GET'): ServerRequest + { + $request = new ServerRequest($method, "http://verifier.example:8080/dwa?$query"); + return $cookie === null ? $request : $request->withCookieParams([SessionCookie::NAME => $cookie]); + } + + private function settle(PromiseInterface $promise): ResponseInterface + { + $result = null; + $promise->then(function (ResponseInterface $r) use (&$result): void { $result = $r; }, function (Throwable $e): void { throw $e; }); + self::assertInstanceOf(ResponseInterface::class, $result, 'promise did not settle synchronously'); + return $result; + } + + private function cookieOf(ResponseInterface $response): string + { + self::assertMatchesRegularExpression('/^verifier_session=[0-9a-f]{32}; Path=\/; Max-Age=86400; HttpOnly; SameSite=Lax$/', $response->getHeaderLine('Set-Cookie')); + return explode('=', explode(';', $response->getHeaderLine('Set-Cookie'))[0], 2)[1]; + } + + public function testLoginMintsACookieAndAnUnguessableStatePerBrowser(): void + { + $endpoint = $this->discord(new FakeProvider([])); + $first = $this->settle($endpoint->handleAsync('GET', $this->request('login'))); + $second = $this->settle($endpoint->handleAsync('GET', $this->request('login'))); + self::assertSame(302, $first->getStatusCode()); + $a = $this->cookieOf($first); $b = $this->cookieOf($second); + self::assertNotSame($a, $b, 'two browsers without cookies get distinct sessions'); + parse_str(parse_url($first->getHeaderLine('Location'), PHP_URL_QUERY), $query); + self::assertSame('https://discord.com/api/v10/oauth2/authorize', explode('?', $first->getHeaderLine('Location'))[0]); + self::assertSame('client-id', $query['client_id']); + self::assertSame('http://verifier.example:8080/dwa', $query['redirect_uri']); + self::assertSame($this->sessions['dwa'][$a]['state'], $query['state']); + self::assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $query['state']); + self::assertNotSame($this->sessions['dwa'][$a]['state'], $this->sessions['dwa'][$b]['state']); + // A returning browser keeps its session and gets no new cookie. + $again = $this->settle($endpoint->handleAsync('GET', $this->request('login', $a))); + self::assertSame('', $again->getHeaderLine('Set-Cookie')); + self::assertCount(2, $this->sessions['dwa']); + } + + public function testCodeExchangeStoresTheProfileUnderTheCookieOnly(): void + { + $provider = new FakeProvider([ + '/api/v10/oauth2/token' => ['access_token' => 'tok-123', 'token_type' => 'Bearer'], + '/api/v10/users/@me' => ['id' => '123456789012345678', 'username' => 'alice'], + ]); + $endpoint = $this->discord($provider); + $cookie = $this->cookieOf($this->settle($endpoint->handleAsync('GET', $this->request('login')))); + $state = $this->sessions['dwa'][$cookie]['state']; + + $wrong = $this->settle($endpoint->handleAsync('GET', $this->request("code=abc&state=nope", $cookie))); + self::assertSame(400, $wrong->getStatusCode()); + self::assertSame([], $provider->calls, 'no provider call before the state is verified'); + + $ok = $this->settle($endpoint->handleAsync('GET', $this->request("code=abc&state=$state", $cookie))); + self::assertSame(302, $ok->getStatusCode()); + self::assertSame('http://verifier.example:8080', $ok->getHeaderLine('Location')); + self::assertSame('POST', $provider->calls[0][0]); + parse_str($provider->calls[0][3], $tokenRequest); + self::assertSame(['client_id' => 'client-id', 'client_secret' => 'client-secret', 'grant_type' => 'authorization_code', 'code' => 'abc', 'redirect_uri' => 'http://verifier.example:8080/dwa'], $tokenRequest); + self::assertSame('Bearer tok-123', $provider->calls[1][2]['Authorization']); + self::assertSame('123456789012345678', $this->sessions['dwa'][$cookie]['user']->id); + self::assertArrayNotHasKey('state', $this->sessions['dwa'][$cookie], 'a code is exchanged once per login'); + + // The profile is served to the browser holding the cookie and to nobody else. + $mine = $this->settle($endpoint->handleAsync('GET', $this->request('user', $cookie))); + self::assertSame('alice', json_decode((string) $mine->getBody())->username); + $stranger = $this->settle($endpoint->handleAsync('GET', $this->request('user'))); + self::assertSame('[]', (string) $stranger->getBody()); + self::assertCount(2, $provider->calls, 'cached profile is not fetched again'); + + $this->settle($endpoint->handleAsync('GET', $this->request('logout', $cookie))); + self::assertArrayNotHasKey('user', $this->sessions['dwa'][$cookie]); + self::assertSame('[]', (string) $this->settle($endpoint->handleAsync('GET', $this->request('user', $cookie)))->getBody()); + } + + public function testTheSameCookieLinksDiscordAndSs14SessionsForTheBot(): void + { + $discord = $this->discord(new FakeProvider([ + '/api/v10/oauth2/token' => ['access_token' => 'd'], '/api/v10/users/@me' => ['id' => '123456789012345678'], + ])); + $ss14 = new SS14OAuth2Endpoint($this->sessions, '203.0.113.5', 'verifier.example', 8080, 'ss14-id', 'ss14-secret', new FakeProvider([ + '/connect/token' => ['access_token' => 's'], '/connect/userinfo' => ['name' => 'Alice'], + ])); + $cookie = $this->cookieOf($this->settle($discord->handleAsync('GET', $this->request('login')))); + $this->settle($discord->handleAsync('GET', $this->request('code=x&state=' . $this->sessions['dwa'][$cookie]['state'], $cookie))); + $this->settle($ss14->handleAsync('GET', (new ServerRequest('GET', 'http://verifier.example:8080/ss14wa?login'))->withCookieParams([SessionCookie::NAME => $cookie]))); + $this->settle($ss14->handleAsync('GET', (new ServerRequest('GET', 'http://verifier.example:8080/ss14wa?code=y&state=' . $this->sessions['ss14wa'][$cookie]['state']))->withCookieParams([SessionCookie::NAME => $cookie]))); + // Civilizationbot's Civ14 verifier walks $sessions['dwa'] for the Discord id and reads $sessions['ss14wa'][]['user']->name. + $key = null; + foreach ($this->sessions['dwa'] as $id => $session) if (($session['user']->id ?? null) === '123456789012345678') $key = $id; + self::assertSame($cookie, $key); + self::assertSame('Alice', $this->sessions['ss14wa'][$key]['user']->name); + } + + public function testIdleSessionsExpireAndForgedCookiesAreReplaced(): void + { + $endpoint = $this->discord(new FakeProvider([])); + $old = $this->cookieOf($this->settle($endpoint->handleAsync('GET', $this->request('login')))); + $this->sessions['dwa'][$old]['touched'] = time() - SessionCookie::TTL - 1; + $forged = $this->settle($endpoint->handleAsync('GET', $this->request('login', '../etc/passwd'))); + self::assertNotSame('', $forged->getHeaderLine('Set-Cookie'), 'an invalid cookie value gets a fresh id'); + self::assertArrayNotHasKey($old, $this->sessions['dwa']); + self::assertArrayNotHasKey('../etc/passwd', $this->sessions['dwa']); + self::assertCount(1, $this->sessions['dwa']); + } + + public function testProviderCallsDoNotBlockTheServer(): void + { + $provider = new FakeProvider(['/api/v10/oauth2/token' => ['access_token' => 't'], '/api/v10/users/@me' => ['id' => '1']], true); + $endpoint = $this->discord($provider); + $cookie = $this->cookieOf($this->settle($endpoint->handleAsync('GET', $this->request('login')))); + $state = $this->sessions['dwa'][$cookie]['state']; + + // Through the ReactPHP server the exchange returns a pending promise... + $server = new Server('127.0.0.1', 8080); + $server->setState(new VerifierServer\PersistentState('test-token')); + (new ReflectionProperty(Server::class, 'endpoints'))->setValue($server, ['/dwa' => $endpoint]); + $pending = (new ReflectionMethod(Server::class, 'handleReact'))->invoke($server, $this->request("code=abc&state=$state", $cookie)); + self::assertInstanceOf(PromiseInterface::class, $pending); + $result = null; + $pending->then(function (ResponseInterface $r) use (&$result): void { $result = $r; }); + self::assertNull($result); + $provider->settleAll(); // token answer arrives + $provider->settleAll(); // profile answer arrives + self::assertSame(302, $result->getStatusCode()); + self::assertSame('1', $this->sessions['dwa'][$cookie]['user']->id); + + // ...while the synchronous in-process adapter cannot wait and says so. + $status = 0; $headers = []; $body = ''; + $endpoint->handle('GET', $this->request('login', $cookie), $status, $headers, $body); + self::assertSame(302, $status); + $endpoint->handle('GET', $this->request("code=abc&state=" . $this->sessions['dwa'][$cookie]['state'], $cookie), $status, $headers, $body); + self::assertSame(302, $status, 'already authenticated: no provider call needed'); + $this->sessions['dwa'][$cookie] = ['touched' => time(), 'state' => 'st']; + $endpoint->handle('GET', $this->request('code=abc&state=st', $cookie), $status, $headers, $body); + self::assertSame(503, $status); + } + + public function testProviderFailureIsA500ThroughTheServer(): void + { + $failing = static fn(): PromiseInterface => \React\Promise\reject(new RuntimeException('provider down')); + $endpoint = new DiscordOAuth2Endpoint($this->sessions, '203.0.113.5', 'verifier.example', 8080, 'id', 'secret', $failing); + $cookie = $this->cookieOf($this->settle($endpoint->handleAsync('GET', $this->request('login')))); + $server = new Server('127.0.0.1', 8080); + (new ReflectionProperty(Server::class, 'endpoints'))->setValue($server, ['/dwa' => $endpoint]); + $response = $this->settle((new ReflectionMethod(Server::class, 'handleReact'))->invoke($server, $this->request('code=abc&state=' . $this->sessions['dwa'][$cookie]['state'], $cookie))); + self::assertSame(500, $response->getStatusCode()); + self::assertSame(405, $this->settle($endpoint->handleAsync('DELETE', $this->request('login')))->getStatusCode()); + self::assertSame(400, $this->settle($endpoint->handleAsync('GET', $this->request('')))->getStatusCode()); + } +}