diff --git a/lib/REST/Microsub/Auth.php b/lib/REST/Microsub/Auth.php index 3f5f0f27..698807ff 100644 --- a/lib/REST/Microsub/Auth.php +++ b/lib/REST/Microsub/Auth.php @@ -11,12 +11,18 @@ use JKingWeb\Arsse\Misc\URL; use JKingWeb\Arsse\Misc\Date; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; -use Zend\Diactoros\Response\HtmlResponse as Response; +use Zend\Diactoros\Response\HtmlResponse; +use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\EmptyResponse; class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { /** The scopes which we grant to Microsub clients. Mute and block are not included because they have no meaning in an RSS/Atom context; this may signal to clients to suppress muting and blocking in their UI */ const SCOPES = "read follow channels"; + const FUNCTIONS = [ + 'discovery' => ['GET' => "opDiscovery"], + 'login' => ['GET' => "opLogin", 'POST' => "opCodeVerification"], + 'issue' => ['POST' => "opIssue"], + ]; public function __construct() { } @@ -30,11 +36,21 @@ class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { } $id = rawurldecode($id); // gather the query parameters and act on the "proc" parameter - $method = "do".ucfirst(strtolower($req->getQueryParams()['proc'] ?? "discovery")); - if (!method_exists($this, $method)) { + $process = $req->getQueryParams()['proc'] ?? "discovery"; + $method = $req->getMethod(); + if (isset(self::FUNCTIONS[$process])) { 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"; + } + return new EmptyResponse(204, $fields); + } elseif (isset(self::FUNCTIONS[$process][$method])) { + return new EmptyResponse(405, ['Allow' => implode(",", array_keys(self::FUNCTIONS[$process]))]); } else { - return $this->$method($id, $req); + $func = self::FUNCTIONS[$process][$method]; + return $this->$func($id, $req); } } @@ -63,8 +79,10 @@ class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { * Since discovery is publicly accessible, we produce a discovery * page for all potential user name so as not to facilitate user * enumeration + * + * @see https://indieweb.org/Microsub-spec#Discovery */ - protected function doDiscovery(string $user, ServerRequestInterface $req): ResponseInterface { + protected function opDiscovery(string $user, ServerRequestInterface $req): ResponseInterface { $base = $this->buildIdentifier($req, true); $id = $this->buildIdentifier($req); $urlAuth = $id."?proc=login"; @@ -72,7 +90,7 @@ class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { $urlService = $base."microsub"; // output an extremely basic identity resource $html = ''; - return new Response($html, 200, [ + return new HtmlResponse($html, 200, [ "Link: <$urlAuth>; rel=\"authorization_endpoint\"", "Link: <$urlToken>; rel=\"token_endpoint\"", "Link: <$urlService>; rel=\"microsub\"", @@ -85,8 +103,10 @@ class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { * challenge; once the user successfully logs in a code is issued * and redirection occurs. Scopes are for all intents and purposes * ignored and client information is not presented. + * + * @see https://indieauth.spec.indieweb.org/#authentication-request */ - protected function doLogin(string $user, ServerRequestInterface $req): ResponseInterface { + protected function opLogin(string $user, ServerRequestInterface $req): ResponseInterface { if (!$req->getAttribute("authenticated", false)) { // user has not yet logged in, or has failed to log in return new EmptyResponse(401); @@ -105,15 +125,59 @@ class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { if (!URL::absolute($redir)) { return new EmptyResponse(400); } + // store the client ID and redirect URL + $data = json_encode([ + 'id' => $query['client_id'], + 'url' => $query['redirect_uri'], + ],\JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); // issue an authorization code and build the redirect URL - $code = Arsse::$db->tokenCreate($id, "microsub.auth", null, Date::add("PT2M"), $query['client_id']); + $code = Arsse::$db->tokenCreate($id, "microsub.auth", null, Date::add("PT2M"), $data); $next = URL::queryAppend($redir, "code=$code&state=$state"); return new EmptyResponse(302, ["Location: $next"]); } } } - protected function doIssue(string $user, ServerRequestInterface $req): ResponseInterface { + /** Validates an authorization code against client-provided values + * + * The redirect URL and client ID are checked, as is the user ID + * + * If everything checks out the canonical user URL is supposed to be returned; + * we don't actually know what the canonical URL is modulo URL encoding, but it + * doesn't actually matter for our purposes + * + * @see https://indieauth.spec.indieweb.org/#authorization-code-verification + */ + protected function opCodeVerification(string $user, ServerRequestInterface $req): ResponseInterface { + $post = $req->getParsedBody(); + try { + // validate the request parameters + $code = $post['code'] ?? ""; + $id = $post['client_id'] ?? ""; + $url = $post['redirect_uri'] ?? ""; + if (!strlen($code) || !strlen($id) || !strlen($url)) { + throw new ExceptionAuth("invalid_request"); + } + // check that the token exists + $token = Arsse::$db->tokenLookup("microsub.auth", $code); + if (!$token) { + throw new ExceptionAuth("unsupported_grant_type"); + } + $data = @json_decode($token['data'], true); + // validate the token + if ($token['user'] !== $user || !is_array($data) || $data['id'] !== $id || $data['url'] !== $url) { + throw new ExceptionAuth("unsupported_grant_type"); + } else { + return new JsonResponse(['me' => $this->buildIdentifier($req)]); + } + } catch (ExceptionAuth $e) { + // human-readable error messages could be added, but these must be ASCII per OAuth, so there's probably not much point + // see https://tools.ietf.org/html/rfc6749#section-5.2 + return new JsonResponse(['error' => $e->getMessage()], 400); + } + } + protected function opIssue(string $user, ServerRequestInterface $req): ResponseInterface { + $post = $req->getParsedBody(); } } diff --git a/lib/REST/Microsub/ExceptionAuth.php b/lib/REST/Microsub/ExceptionAuth.php new file mode 100644 index 00000000..28816f21 --- /dev/null +++ b/lib/REST/Microsub/ExceptionAuth.php @@ -0,0 +1,10 @@ +