diff --git a/lib/REST/Microsub/Auth.php b/lib/REST/Microsub/Auth.php index 57bdf645..ced8795a 100644 --- a/lib/REST/Microsub/Auth.php +++ b/lib/REST/Microsub/Auth.php @@ -84,10 +84,29 @@ class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { return URL::normalize(($https ? "https" : "http")."://".$s['HTTP_HOST'].$port."/"); } + /** Produces a canoncial identity URL based on a server request and a user name + * + * This involves reconstructing the scheme and authority based on $_SERVER + * variables; it may fail depending on server configuration + */ protected function buildIdentifier(ServerRequestInterface $req, string $user): string { return $this->buildBaseURL($req)."u/".str_replace(array_keys(self::USERNAME_ESCAPES), array_values(self::USERNAME_ESCAPES), $user); } + /** Matches an identity URL against its canoncial form + * + * The identifier matches if all of the following are true: + * + * 1. The scheme is http or https + * 2. The normalized hostname matches + * 3. The port matches after dropping default port numbers + * 4. No credentials are included in the authority + * 5. The path is `/u/` + * 6. There is no query content + * 7. The username, when URL-decoded, matches + * + * Though IndieAuth forbids port numbers and fragments in identifiers, we do not enforce this + */ protected function matchIdentifier(string $canonical, string $me): bool { $me = parse_url(URL::normalize($me)); $me['scheme'] = $me['scheme'] ?? ""; @@ -138,7 +157,7 @@ class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { ]); } - /** Handles the authentication/authorization process + /** Handles the authentication process * * Authentication is achieved via an HTTP Basic authentiation * challenge; once the user successfully logs in a code is issued @@ -146,6 +165,7 @@ class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { * ignored and client information is not presented. * * @see https://indieauth.spec.indieweb.org/#authentication-request + * @see https://indieauth.spec.indieweb.org/#authorization-endpoint-0 */ protected function opLogin(ServerRequestInterface $req): ResponseInterface { if (!$req->getAttribute("authenticated", false)) { @@ -188,68 +208,80 @@ class Auth extends \JKingWeb\Arsse\REST\AbstractHandler { } } - /** Validates an authorization code against client-provided values + /** Handles the auth code verification of the basic "Authentication" flow of IndieAuth * - * 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 + * This is not used by Microsub * * @see https://indieauth.spec.indieweb.org/#authorization-code-verification - * @see https://indieauth.spec.indieweb.org/#authorization-code-verification-0 */ protected function opCodeVerification(ServerRequestInterface $req): ResponseInterface { $post = $req->getParsedBody(); - // validate the request parameters - $code = $post['code'] ?? ""; - $client = $post['client_id'] ?? ""; - $redir = $post['redirect_uri'] ?? ""; - if (!strlen($code) || !strlen($client) || !strlen($redir)) { + $tr = Arsse::$db->begin(); + // validate the request parameters; an exception will be thrown if not + list($user, $type) = $this->validateAuthCode($post['code'] ?? "", $post['client_id'] ?? "", $post['redirect_uri'] ?? ""); + if ($type !== "id") { + throw new ExceptionAuth("invalid_grant"); + } + // delete the auth code since it is valid and may only be used once + Arsse::$db->tokenRevoke($user, "microsub.auth", $post['code']); + $tr->commit(); + // return the canonical identity URL + return new JsonResponse(['me' => $this->buildIdentifier($req, $user)]); + } + + /** Handles the auth code verification and token issuance of the "Authorization" flow of IndieAuth + * + * @see https://indieauth.spec.indieweb.org/#token-endpoint-0 + */ + protected function opIssueAccessToken(ServerRequestInterface $req): ResponseInterface { + $post = $req->getParsedBody(); + if (($post['grant_type'] ?? "") !== "authorization_code") { + throw new ExceptionAuth("unsupported_grant_type"); + } + $tr = Arsse::$db->begin(); + list($user, $type) = $this->validateAuthCode($post['code'] ?? "", $post['client_id'] ?? "", $post['redirect_url'] ?? "", $post['me'] ?? ""); + if ($type !== "code") { + throw new ExceptionAuth("invalid_grant"); + } + // issue an access token + $token = Arsse::$db->tokenCreate($user, "microsub.access"); + Arsse::$db->tokenRevoke($user, "microsub.auth", $post['code']); + $tr->commit(); + // return the Bearer token and associated data + return new JsonResponse([ + 'me' => $this->buildIdentifier($req, $user), + 'token_type' => "Bearer", + 'access_token' => $token, + 'scope' => self::SCOPES, + ]); + } + + /** Validates an auth code and throws appropriate exceptions otherwise + * + * Returns an indexed araay containing the username and the grant type (either "id" or "code") + * + * It is the responsibility of the calling function to revoke the auth code if the code is accepted + */ + protected function validateAuthCode(string $code, string $clientId, string $redirUrl, string $me = null): array { + if (!strlen($code) || !strlen($clientId) || !strlen($redirUrl)) { throw new ExceptionAuth("invalid_request"); } - // check that the token exists + // check that the auth code exists $token = Arsse::$db->tokenLookup("microsub.auth", $code); if (!$token) { throw new ExceptionAuth("invalid_grant"); } $data = @json_decode($token['data'], true); - // validate the token + // validate the auth code if (!is_array($data)) { throw new ExceptionAuth("invalid_grant"); - } elseif ($data['client'] !== $client || $data['redir'] !== $redir) { + } elseif ($data['client'] !== $clientId || $data['redir'] !== $redirUrl) { throw new ExceptionAuth("invalid_client"); - } else { - $out = ['me' => $this->buildIdentifier($req, $token['user'])]; - if ($data['type'] === "code") { - $out['scope'] = self::SCOPES; - } - return new JsonResponse($out); - } - } - - protected function opIssueAccessToken(ServerRequestInterface $req): ResponseInterface { - $post = $req->getParsedBody(); - $type = $post['grant_type'] ?? ""; - $me = $post['me'] ?? ""; - if ($type !== "authorization_code") { - throw new ExceptionAuth("unsupported_grant_type"); - } elseif ($this->buildIdentifier($req) !== $me) { + } elseif (isset($me) && $me !== $data['me']) { throw new ExceptionAuth("invalid_grant"); - } else { - $out = $this->opCodeVerification($user, $req)->getPayload(); - if (!isset($out['scope'])) { - throw new ExceptionAuth("invalid_scope"); - } - // issue an access token - $tr = Arsse::$db->begin(); - $token = Arsse::$db->tokenCreate($user, "microsub.access"); - Arsse::$db->tokenRevoke($user, "microsub.auth", $post['code']); - $tr->commit(); - $out['access_token'] = $token; - $out['token_type'] = "Bearer"; - return new JsonResponse($out); } + // return the associated user name and the auth-code type + return [$token['user'], $data['type'] ?? "id"]; } protected function opTokenVerification(string $user, ServerRequestInterface $req): ResponseInterface {