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()); + } +}