diff --git a/lib/REST/Microsub/Auth.php b/lib/REST/Microsub/Auth.php index ebc006e4..e3bf51f5 100644 --- a/lib/REST/Microsub/Auth.php +++ b/lib/REST/Microsub/Auth.php @@ -9,6 +9,7 @@ namespace JKingWeb\Arsse\REST\Microsub; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Misc\URL; use JKingWeb\Arsse\Misc\Date; +use JKingWeb\Arsse\Misc\ValueInfo; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\HtmlResponse; @@ -24,13 +25,21 @@ class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { 'auth' => ['GET' => "opLogin", 'POST' => "opCodeVerification"], 'token' => ['GET' => "opTokenVerification", 'POST' => "opIssueAccessToken"], ]; - /** The minimal set of reserved URL characters which must be escaped when comparing user ID URLs */ + /** The minimal set of reserved URL characters which mus t be escaped when comparing user ID URLs */ const USERNAME_ESCAPES = [ '#' => "%23", '%' => "%25", '/' => "%2F", '?' => "%3F", ]; + /** The minimal set of reserved URL characters which must be escaped in query values */ + const QUERY_ESCAPES = [ + '#' => "%23", + '%' => "%25", + '&' => "%26", + ]; + /** The acceptable media type of input for POST requests */ + const ACCEPTED_TYPES = "application/x-www-form-urlencoded"; public function __construct() { } @@ -44,19 +53,25 @@ class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { // gather the query parameters and act on the "f" (function) parameter $process = $req->getQueryParams()['f'] ?? ""; $method = $req->getMethod(); - if (isset(self::FUNCTIONS[$process]) || ($process === "" && !strlen($path)) || ($process !== "" && strlen($path))) { + if (!isset(self::FUNCTIONS[$process]) || ($process === "" && !strlen($path)) || ($process !== "" && strlen($path))) { // the function requested needs to exist // the path should also be empty unless we're doing discovery return new EmptyResponse(404); } elseif ($method === "OPTIONS") { $fields = ['Allow' => implode(",", array_keys(self::FUNCTIONS[$process]))]; if (isset(self::FUNCTIONS[$process]['POST'])) { - $fields['Accept'] = "application/x-www-form-urlencoded"; + $fields['Accept'] = self::ACCEPTED_TYPES; } return new EmptyResponse(204, $fields); - } elseif (isset(self::FUNCTIONS[$process][$method])) { + } elseif (!isset(self::FUNCTIONS[$process][$method])) { return new EmptyResponse(405, ['Allow' => implode(",", array_keys(self::FUNCTIONS[$process]))]); } else { + if ($req->getMethod() !== "GET") { + $type = $req->getHeaderLine("Content-Type") ?? ""; + if (strlen($type) && strtolower($type) !== self::ACCEPTED_TYPES) { + return new EmptyResponse(415, ['Accept' => self::ACCEPTED_TYPES]); + } + } try { $func = self::FUNCTIONS[$process][$method]; return $this->$func($req); @@ -76,8 +91,7 @@ class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { protected function buildBaseURL(ServerRequestInterface $req): string { // construct the base user identifier URL; the user is never checked against the database $s = $req->getServerParams(); - $path = $req->getRequestTarget()['path']; - $https = (strlen($s['HTTPS'] ?? "") && $s['HTTPS'] !== "off"); + $https = ValueInfo::normalize($s['HTTPS'] ?? "", ValueInfo::T_BOOL); $port = (int) ($s['SERVER_PORT'] ?? 0); $port = (!$port || ($https && $port == 443) || (!$https && $port == 80)) ? "" : ":$port"; return URL::normalize(($https ? "https" : "http")."://".$s['HTTP_HOST'].$port."/"); @@ -149,11 +163,11 @@ class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { $urlService = $base."microsub"; // output an extremely basic identity resource $html = ''; - return new HtmlResponse($html, 200, [ - "Link: <$urlAuth>; rel=\"authorization_endpoint\"", - "Link: <$urlToken>; rel=\"token_endpoint\"", - "Link: <$urlService>; rel=\"microsub\"", - ]); + return new HtmlResponse($html, 200, ['Link' => [ + "<$urlAuth>; rel=\"authorization_endpoint\"", + "<$urlToken>; rel=\"token_endpoint\"", + "<$urlService>; rel=\"microsub\"", + ]]); } /** Handles the authentication process @@ -311,7 +325,7 @@ class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { 'invalid_request' => 400, 'invalid_token' => 401, ][$errCode] ?? 500; - return new EmptyResponse($httpCode, ['WWW-Authenticate' => "Bearer error=\"$erroCode\""]); + return new EmptyResponse($httpCode, ['WWW-Authenticate' => "Bearer error=\"$errCode\""]); } return new JsonResponse([ 'me' => $data['me'] ?? "", diff --git a/tests/cases/REST/Microsub/TestAuth.php b/tests/cases/REST/Microsub/TestAuth.php index f4eb5797..595599aa 100644 --- a/tests/cases/REST/Microsub/TestAuth.php +++ b/tests/cases/REST/Microsub/TestAuth.php @@ -7,10 +7,80 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\REST\Microsub; use Psr\Http\Message\ResponseInterface; -use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Response\JsonResponse as Response; use Zend\Diactoros\Response\EmptyResponse; +use Zend\Diactoros\Response\HtmlResponse; /** @covers \JKingWeb\Arsse\REST\Microsub\Auth */ class TestAuth extends \JKingWeb\Arsse\Test\AbstractTest { + public function req(string $url, string $method = "GET", array $data = [], array $headers = [], string $type = "application/x-www-form-urlencoded", string $body = null): ResponseInterface { + $type = (strtoupper($method) === "GET") ? "" : $type; + $req = $this->serverRequest($method, $url, "/u/", $headers, [], $body ?? $data, $type); + return (new \JKingWeb\Arsse\REST\Microsub\Auth)->dispatch($req); + } + + /** @dataProvider provideInvalidRequests */ + public function testHandleInvalidRequests(ResponseInterface $exp, string $method, string $url, string $type = null) { + $act = $this->req("http://example.com".$url, $method, [], [], $type ?? ""); + $this->assertMessage($exp, $act); + } + + public function provideInvalidRequests() { + $r404 = new EmptyResponse(404); + $r405g = new EmptyResponse(405, ['Allow' => "GET"]); + $r405gp = new EmptyResponse(405, ['Allow' => "GET,POST"]); + $r415 = new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"]); + return [ + [$r404, "GET", "/u/"], + [$r404, "GET", "/u/john.doe/hello"], + [$r404, "GET", "/u/john.doe/"], + [$r404, "GET", "/u/john.doe?f=hello"], + [$r404, "GET", "/u/?f="], + [$r404, "GET", "/u/?f=goodbye"], + [$r405g, "POST", "/u/john.doe"], + [$r405gp, "PUT", "/u/?f=token"], + [$r404, "POST", "/u/john.doe?f=token"], + [$r415, "POST", "/u/?f=token", "application/json"], + ]; + } + + /** @dataProvider provideOptionsRequests */ + public function testHandleOptionsRequests(string $url, array $headerFields) { + $exp = new EmptyResponse(204, $headerFields); + $this->assertMessage($exp, $this->req("http://example.com".$url, "OPTIONS")); + } + + public function provideOptionsRequests() { + $ident = ['Allow' => "GET"]; + $other = ['Allow' => "GET,POST", 'Accept' => "application/x-www-form-urlencoded"]; + return [ + ["/u/john.doe", $ident], + ["/u/?f=token", $other], + ["/u/?f=auth", $other], + ]; + } + + /** @dataProvider provideDiscoveryRequests */ + public function testDiscoverAUser(string $url, string $origin) { + $auth = $origin."/u/?f=auth"; + $token = $origin."/u/?f=token"; + $microsub = $origin."/microsub"; + $exp = new HtmlResponse('', 200, ['Link' => [ + "<$auth>; rel=\"authorization_endpoint\"", + "<$token>; rel=\"token_endpoint\"", + "<$microsub>; rel=\"microsub\"", + ]]); + $this->assertMessage($exp, $this->req($url)); + } + + public function provideDiscoveryRequests() { + return [ + ["http://example.com/u/john.doe", "http://example.com"], + ["http://example.com:80/u/john.doe", "http://example.com"], + ["https://example.com/u/john.doe", "https://example.com"], + ["https://example.com:443/u/john.doe", "https://example.com"], + ["http://example.com:443/u/john.doe", "http://example.com:443"], + ["https://example.com:80/u/john.doe", "https://example.com:80"], + ]; + } } diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index a2e66a31..06f9bbfe 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -64,13 +64,12 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { } protected function serverRequest(string $method, string $url, string $urlPrefix, array $headers = [], array $vars = [], $body = null, string $type = "", $params = [], string $user = null): ServerRequestInterface { - $server = [ - 'REQUEST_METHOD' => $method, - 'REQUEST_URI' => $url, - ]; + // build an initial $_SERVER array + $server = ['REQUEST_METHOD' => strtoupper($method)]; if (strlen($type)) { $server['HTTP_CONTENT_TYPE'] = $type; } + // add any specified parameters to the URL if (isset($params)) { if (is_array($params)) { $params = implode("&", array_map(function($v, $k) { @@ -79,12 +78,27 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { } $url = URL::queryAppend($url, (string) $params); } + // glean the scheme, hostname, and port from the URL + $urlParts = parse_url($url); + if (isset($urlParts['scheme'])) { + $server['HTTPS'] = strtolower($urlParts['scheme']) === "https" ? "on" : "off"; + } + if (isset($urlParts['host'])) { + $server['HTTP_HOST'] = $urlParts['host']; + if (isset($urlParts['port'])) { + $server['SERVER_PORT'] = $urlParts['port']; + } + $url = $urlParts['path'].(isset($urlParts['query']) ? "?".$urlParts['query'] : ""); + } + $server['REQUEST_URI'] = $url; + // rebuild the parsed query parameters from the URL $q = parse_url($url, \PHP_URL_QUERY); if (strlen($q ?? "")) { parse_str($q, $params); } else { $params = []; } + // prepare the body and parsed body $parsedBody = null; if (isset($body)) { if (is_string($body) && in_array(strtolower($type), ["", "application/x-www-form-urlencoded"])) { @@ -96,8 +110,12 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { $body = http_build_query($body, "a", "&"); } } + // add any override values to the $_SERVER array $server = array_merge($server, $vars); + // create the request $req = new ServerRequest($server, [], $url, $method, "php://memory", [], [], $params, $parsedBody); + // if a user is specified, act as if they were authenticated by the global handler + // the empty string denotes failed authentication if (isset($user)) { if (strlen($user)) { $req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", $user); @@ -105,9 +123,11 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { $req = $req->withAttribute("authenticationFailed", true); } } - if (strlen($type) &&strlen($body ?? "")) { + // if a content type was specified, add it as a header + if (strlen($type)) { $req = $req->withHeader("Content-Type", $type); } + // add any other headers foreach ($headers as $key => $value) { if (!is_null($value)) { $req = $req->withHeader($key, $value); @@ -115,13 +135,16 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { $req = $req->withoutHeader($key); } } + // strip the URL prefix from the request target, as the global handler does $target = substr(URL::normalize($url), strlen($urlPrefix)); $req = $req->withRequestTarget($target); + // add the body text, if any if (strlen($body ?? "")) { $p = $req->getBody(); $p->write($body); $req = $req->withBody($p); } + // return the prepared request return $req; }