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;
}