Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()[<endpoint>][<session id>]`. 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:
Expand Down
85 changes: 35 additions & 50 deletions src/Discord/OAuth2Authenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,83 +8,68 @@

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';
protected string $issuer = 'https://discord.com/api/v10';
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_<type>_id` / `oauth_<type>_name` (plus `oauth_steam_url` for Steam).
*
* Session keys set:
* - oauth_<type>_id: The ID of the connection account.
* - oauth_<type>_name: The username of the connection account.
* - oauth_steam_url (if type is "steam"): The Steam profile URL for the connection account.
* @return PromiseInterface<array|null>
*/
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);
}
}
}
25 changes: 7 additions & 18 deletions src/SS14/OAuth2Authenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
23 changes: 23 additions & 0 deletions src/VerifierServer/Endpoints/Interfaces/AsyncEndpointInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php declare(strict_types=1);

namespace VerifierServer\Endpoints\Interfaces;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use React\Promise\PromiseInterface;

/**
* An endpoint whose work may involve waiting on another service.
*
* The ReactPHP server hands the request straight to handleAsync() and
* answers when the returned promise settles, so the event loop keeps
* serving other requests meanwhile. The synchronous handle() from
* EndpointInterface stays available for in-process callers.
*/
interface AsyncEndpointInterface extends EndpointInterface
{
/**
* @return PromiseInterface<ResponseInterface>
*/
public function handleAsync(string $method, ServerRequestInterface $request): PromiseInterface;
}
Loading
Loading