Compare commits
34 Commits
10 changed files with 811 additions and 19 deletions
@ -0,0 +1,19 @@ |
|||||
|
[TOC] |
||||
|
|
||||
|
# About |
||||
|
|
||||
|
<dl> |
||||
|
<dt>Supported since</dt> |
||||
|
<dd>0.9.0</dd> |
||||
|
<dt>Base URL</dt> |
||||
|
<dd>/</dd> |
||||
|
<dt>API endpoint</dt> |
||||
|
<dd>/u/<var>username</var></dd> |
||||
|
<dd>/microsub</dd> |
||||
|
<dt>Specifications</dt> |
||||
|
<dd><a href="https://indieweb.org/Microsub-spec">Microsub</a>, <a href="https://indieauth.spec.indieweb.org/">IndieAuth</a></dd> |
||||
|
</dl> |
||||
|
|
||||
|
The Microsub protocol is a facet of the [IndieWeb movement](https://indieweb.org/), and unlike other protocols is designed as a first-class interface between news-reading clients and servers. |
||||
|
|
||||
|
As IndieWeb technology is sprawling and complex, The Arsse only implements enough so that Microsub clients can be used with The Arsse. Consequently The Arsse does not function as a generic IndieAuth authorizer, will not work with arbitrary IndieWeb identifier URLs, does not implement any Micropub functionality, and so on. |
@ -0,0 +1,391 @@ |
|||||
|
<?php |
||||
|
/** @license MIT |
||||
|
* Copyright 2017 J. King, Dustin Wilson et al. |
||||
|
* See LICENSE and AUTHORS files for details */ |
||||
|
|
||||
|
declare(strict_types=1); |
||||
|
namespace JKingWeb\Arsse\REST\Microsub; |
||||
|
|
||||
|
use JKingWeb\Arsse\Arsse; |
||||
|
use JKingWeb\Arsse\Misc\URL; |
||||
|
use JKingWeb\Arsse\Misc\Date; |
||||
|
use JKingWeb\Arsse\Misc\HTTP; |
||||
|
use JKingWeb\Arsse\Misc\ValueInfo; |
||||
|
use Psr\Http\Message\ServerRequestInterface; |
||||
|
use Psr\Http\Message\ResponseInterface; |
||||
|
use Laminas\Diactoros\Response\HtmlResponse; |
||||
|
use Laminas\Diactoros\Response\JsonResponse; |
||||
|
use Laminas\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"]; |
||||
|
/** The list of the logical functions of this API, with their implementations */ |
||||
|
const FUNCTIONS = [ |
||||
|
'' => ['GET' => "opDiscovery"], |
||||
|
'auth' => ['GET' => "opLogin", 'POST' => "opCodeVerification"], |
||||
|
'token' => ['GET' => "opTokenVerification", 'POST' => "opIssueAccessToken"], |
||||
|
]; |
||||
|
/** The set of URL characters escaped by rawurlencode() which should be unescaped when constructing user ID URLs */ |
||||
|
const USERNAME_UNESCAPES = [ |
||||
|
'%21' => "!", |
||||
|
'%24' => "$", |
||||
|
'%26' => "&", |
||||
|
'%27' => "'", |
||||
|
'%28' => "(", |
||||
|
'%29' => ")", |
||||
|
'%2A' => "*", |
||||
|
'%2B' => "+", |
||||
|
'%2C' => ",", |
||||
|
'%3A' => ":", |
||||
|
'%3B' => ";", |
||||
|
'%3D' => "=", |
||||
|
'%40' => "@", |
||||
|
]; |
||||
|
/** The acceptable media type of input for POST requests */ |
||||
|
const ACCEPTED_TYPE = "application/x-www-form-urlencoded"; |
||||
|
|
||||
|
public function __construct() { |
||||
|
} |
||||
|
|
||||
|
public function dispatch(ServerRequestInterface $req): ResponseInterface { |
||||
|
// if the path contains a slash, this is not a URL we handle |
||||
|
$path = parse_url($req->getRequestTarget())['path'] ?? ""; |
||||
|
if (strpos($path, "/") !== false) { |
||||
|
return new EmptyResponse(404); |
||||
|
} |
||||
|
// 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))) { |
||||
|
// 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'] = self::ACCEPTED_TYPE; |
||||
|
} |
||||
|
return new EmptyResponse(204, $fields); |
||||
|
} elseif (!isset(self::FUNCTIONS[$process][$method])) { |
||||
|
return new EmptyResponse(405, ['Allow' => implode(",", array_keys(self::FUNCTIONS[$process]))]); |
||||
|
} else { |
||||
|
if ($req->getMethod() !== "GET" && !HTTP::matchType($req, self::ACCEPTED_TYPE, "")) { |
||||
|
return new EmptyResponse(415, ['Accept' => self::ACCEPTED_TYPE]); |
||||
|
} |
||||
|
try { |
||||
|
$func = self::FUNCTIONS[$process][$method]; |
||||
|
return $this->$func($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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** Produces the base URL of a server request |
||||
|
* |
||||
|
* This involves reconstructing the scheme and authority based on $_SERVER |
||||
|
* variables; it may fail depending on server configuration |
||||
|
*/ |
||||
|
protected function buildBaseURL(ServerRequestInterface $req): string { |
||||
|
// construct the base user identifier URL; the user is never checked against the database |
||||
|
$s = $req->getServerParams(); |
||||
|
$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."/"); |
||||
|
} |
||||
|
|
||||
|
/** 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_UNESCAPES), array_values(self::USERNAME_UNESCAPES), rawurlencode($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/<username>` |
||||
|
* 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)); |
||||
|
if (!$me) { |
||||
|
return false; |
||||
|
} |
||||
|
$me['scheme'] = $me['scheme'] ?? ""; |
||||
|
$me['path'] = explode("/", $me['path'] ?? ""); |
||||
|
$me['id'] = rawurldecode(array_pop($me['path']) ?? ""); |
||||
|
$me['port'] = (($me['scheme'] === "http" && ($me['port'] ?? 80) == 80) || ($me['scheme'] === "https" && ($me['port'] ?? 443) == 443)) ? 0 : $me['port'] ?? 0; |
||||
|
$c = parse_url($canonical); |
||||
|
$c['path'] = explode("/", $c['path']); |
||||
|
$c['id'] = rawurldecode(array_pop($c['path'])); |
||||
|
if ( |
||||
|
!in_array($me['scheme'] ?? "", ["http", "https"]) || |
||||
|
($me['host'] ?? "") !== $c['host'] || |
||||
|
$me['path'] != $c['path'] || |
||||
|
$me['id'] !== $c['id'] || |
||||
|
strlen($me['user'] ?? "") || |
||||
|
strlen($me['pass'] ?? "") || |
||||
|
strlen($me['query'] ?? "") || |
||||
|
($me['port'] ?? 0) != ($c['port'] ?? 0) |
||||
|
) { |
||||
|
return false; |
||||
|
} |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
/** Presents a very basic user profile for discovery purposes |
||||
|
* |
||||
|
* The HTML document itself consists only of link elements and an |
||||
|
* encoding declaration; Link header-fields are also included for |
||||
|
* HEAD requests |
||||
|
* |
||||
|
* Since discovery is publicly accessible, we produce a discovery |
||||
|
* page for all potential user names so as not to facilitate user |
||||
|
* enumeration |
||||
|
* |
||||
|
* @see https://indieweb.org/Microsub-spec#Discovery |
||||
|
*/ |
||||
|
protected function opDiscovery(ServerRequestInterface $req): ResponseInterface { |
||||
|
$base = $this->buildBaseURL($req); |
||||
|
$urlAuth = $base."u/?f=auth"; |
||||
|
$urlToken = $base."u/?f=token"; |
||||
|
$urlService = $base."microsub"; |
||||
|
// output an extremely basic identity resource |
||||
|
$html = '<meta charset="UTF-8"><link rel="authorization_endpoint" href="'.htmlspecialchars($urlAuth).'"><link rel="token_endpoint" href="'.htmlspecialchars($urlToken).'"><link rel="microsub" href="'.htmlspecialchars($urlService).'">'; |
||||
|
return new HtmlResponse($html, 200, ['Link' => [ |
||||
|
"<$urlAuth>; rel=\"authorization_endpoint\"", |
||||
|
"<$urlToken>; rel=\"token_endpoint\"", |
||||
|
"<$urlService>; rel=\"microsub\"", |
||||
|
]]); |
||||
|
} |
||||
|
|
||||
|
/** Handles the authentication process |
||||
|
* |
||||
|
* Authentication is achieved via an HTTP Basic authentiation |
||||
|
* 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 |
||||
|
* @see https://indieauth.spec.indieweb.org/#authorization-endpoint-0 |
||||
|
*/ |
||||
|
protected function opLogin(ServerRequestInterface $req): ResponseInterface { |
||||
|
if (!$req->getAttribute("authenticated", false)) { |
||||
|
// user has not yet logged in, or has failed to log in |
||||
|
return new EmptyResponse(401); |
||||
|
} else { |
||||
|
// user has logged in |
||||
|
$query = $req->getQueryParams(); |
||||
|
$redir = URL::normalize($query['redirect_uri']); |
||||
|
// check that the redirect URL is an absolute one |
||||
|
if (!URL::absolute($redir)) { |
||||
|
return new EmptyResponse(400); |
||||
|
} |
||||
|
try { |
||||
|
$state = rawurlencode($query['state'] ?? ""); |
||||
|
// ensure the logged-in user matches the IndieAuth identifier URL |
||||
|
$user = $req->getAttribute("authenticatedUser"); |
||||
|
if (!$this->matchIdentifier($this->buildIdentifier($req, $user), $query['me'])) { |
||||
|
throw new ExceptionAuth("access_denied"); |
||||
|
} |
||||
|
$type = !strlen($query['response_type'] ?? "") ? "id" : $query['response_type']; |
||||
|
if (!in_array($type, ["code", "id"])) { |
||||
|
throw new ExceptionAuth("unsupported_response_type"); |
||||
|
} |
||||
|
// store the identity URL, client ID, redirect URL, and response type |
||||
|
$data = json_encode([ |
||||
|
'me' => $query['me'], |
||||
|
'client_id' => $query['client_id'], |
||||
|
'redirect_uri' => $query['redirect_uri'], |
||||
|
'response_type' => $type, |
||||
|
], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); |
||||
|
// issue an authorization code and build the redirect URL |
||||
|
$code = Arsse::$db->tokenCreate($user, "microsub.auth", null, Date::add("PT2M"), $data); |
||||
|
$next = URL::queryAppend($redir, "state=$state&code=$code"); |
||||
|
return new EmptyResponse(302, ['Location' => $next]); |
||||
|
} catch (ExceptionAuth $e) { |
||||
|
$next = URL::queryAppend($redir, "state=$state&error=".$e->getMessage()); |
||||
|
return new EmptyResponse(302, ['Location' => $next]); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** Handles the auth code verification of the basic "Authentication" flow of IndieAuth |
||||
|
* |
||||
|
* This is not used by Microsub, but is part of the IndieAuth specification |
||||
|
* |
||||
|
* @see https://indieauth.spec.indieweb.org/#authorization-code-verification |
||||
|
*/ |
||||
|
protected function opCodeVerification(ServerRequestInterface $req): ResponseInterface { |
||||
|
$post = $req->getParsedBody(); |
||||
|
$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(); |
||||
|
// revocation is a special case of POSTing to the token URL |
||||
|
if (($post['action'] ?? "") === "revoke") { |
||||
|
return $this->opRevokeToken($req); |
||||
|
} |
||||
|
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_uri'] ?? "", $post['me'] ?? ""); |
||||
|
if ($type !== "code") { |
||||
|
throw new ExceptionAuth("invalid_grant"); |
||||
|
} |
||||
|
// issue an access token |
||||
|
$data = json_encode([ |
||||
|
'me' => $post['me'], |
||||
|
'client_id' => $post['client_id'], |
||||
|
], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); |
||||
|
$token = Arsse::$db->tokenCreate($user, "microsub.access", null, null, $data); |
||||
|
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' => implode(" ", self::SCOPES), |
||||
|
]); |
||||
|
} |
||||
|
|
||||
|
/** Validates an auth code and throws appropriate exceptions otherwise |
||||
|
* |
||||
|
* Returns an indexed array 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 ultimately accepted |
||||
|
*/ |
||||
|
protected function validateAuthCode(string $code, string $clientId, string $redirUrl, string $me = null): array { |
||||
|
if (!strlen($code) || !strlen($clientId) || !strlen($redirUrl) || (isset($me) && !strlen($me))) { |
||||
|
throw new ExceptionAuth("invalid_request"); |
||||
|
} |
||||
|
// check that the auth code exists |
||||
|
try { |
||||
|
$token = Arsse::$db->tokenLookup("microsub.auth", $code); |
||||
|
} catch (\JKingWeb\Arsse\Db\ExceptionInput $e) { |
||||
|
throw new ExceptionAuth("invalid_grant"); |
||||
|
} |
||||
|
$data = @json_decode((string) $token['data'], true); |
||||
|
// validate the auth code |
||||
|
if (!is_array($data) || !isset($data['redirect_uri']) || !isset($data['client_id']) || (isset($me) && !isset($data['me']))) { |
||||
|
throw new ExceptionAuth("invalid_grant"); |
||||
|
} elseif ($data['client_id'] !== $clientId || $data['redirect_uri'] !== $redirUrl) { |
||||
|
throw new ExceptionAuth("invalid_client"); |
||||
|
} elseif (isset($me) && $me !== $data['me']) { |
||||
|
throw new ExceptionAuth("invalid_grant"); |
||||
|
} |
||||
|
// return the associated user name and the auth-code type |
||||
|
return [$token['user'], $data['response_type'] ?? "id"]; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Handles token verification as an API call; this will not normally be used since |
||||
|
* the token and service endpoints are tightly coupled |
||||
|
* |
||||
|
* The static `validateBearer` method should be used to check the validity of a bearer token in normal use |
||||
|
* |
||||
|
* @see https://indieauth.spec.indieweb.org/#access-token-verification |
||||
|
*/ |
||||
|
protected function opTokenVerification(ServerRequestInterface $req): ResponseInterface { |
||||
|
try { |
||||
|
if (!$req->hasHeader("Authorization")) { |
||||
|
throw new ExceptionAuth("invalid_token"); |
||||
|
} |
||||
|
$authorization = $req->getHeader("Authorization"); |
||||
|
if (sizeof($authorization) > 1) { |
||||
|
throw new ExceptionAuth("invalid_request"); |
||||
|
} |
||||
|
list($user, $data) = self::validateBearer($authorization[0]); |
||||
|
} catch (ExceptionAuth $e) { |
||||
|
$errCode = $e->getMessage(); |
||||
|
$httpCode = [ |
||||
|
'invalid_request' => 400, |
||||
|
'invalid_token' => 401, |
||||
|
][$errCode] ?? 500; |
||||
|
$out = new EmptyResponse($httpCode, ['WWW-Authenticate' => "Bearer error=\"$errCode\""]); |
||||
|
if ($httpCode == 401) { |
||||
|
$out = $out->withHeader("X-Arsse-Suppress-General-Auth", "1"); |
||||
|
} |
||||
|
return $out; |
||||
|
} |
||||
|
return new JsonResponse([ |
||||
|
'me' => $data['me'] ?? "", |
||||
|
'client_id' => $data['client_id'] ?? "", |
||||
|
'scope' => implode(" ", (array) ($data['scope'] ?? self::SCOPES)), |
||||
|
]); |
||||
|
} |
||||
|
|
||||
|
/** Handles token revocation |
||||
|
* |
||||
|
* @see https://indieauth.spec.indieweb.org/#token-revocation |
||||
|
*/ |
||||
|
protected function opRevokeToken(ServerRequestInterface $req): ResponseInterface { |
||||
|
$token = ($req->getParsedBody() ?? [])['token'] ?? ""; |
||||
|
if (!strlen($token)) { |
||||
|
return new EmptyResponse(422); |
||||
|
} |
||||
|
try { |
||||
|
$info = Arsse::$db->tokenLookup("microsub.access", $token); |
||||
|
Arsse::$db->tokenRevoke($info['user'], "microsub.access", $token); |
||||
|
} catch (\JKingWeb\Arsse\Db\ExceptionInput $e) { |
||||
|
} |
||||
|
return new EmptyResponse(200); |
||||
|
} |
||||
|
|
||||
|
/** Checks that the supplied bearer token is valid i.e. logs a bearer in |
||||
|
* |
||||
|
* Returns an indexed array with the user associated with the token, as well as other data |
||||
|
* |
||||
|
* @throws \JKingWeb\Arsse\REST\Microsub\ExceptionAuth |
||||
|
*/ |
||||
|
public static function validateBearer(string $authorization, array $scopes = []): array { |
||||
|
if (!preg_match("<^Bearer ([a-z0-9\._~/+-]+=*)$>i", $authorization, $match)) { |
||||
|
throw new ExceptionAuth("invalid_request"); |
||||
|
} |
||||
|
$token = $match[1]; |
||||
|
try { |
||||
|
$token = Arsse::$db->tokenLookup("microsub.access", $token); |
||||
|
} catch (\JKingWeb\Arsse\Db\ExceptionInput $e) { |
||||
|
throw new ExceptionAuth("invalid_token"); |
||||
|
} |
||||
|
$data = @json_decode((string) $token['data'], true) ?? []; |
||||
|
$data['scope'] = $data['scope'] ?? self::SCOPES; |
||||
|
// scope is hard-coded for now |
||||
|
if (array_diff($scopes, $data['scope'])) { |
||||
|
throw new ExceptionAuth("insufficient_scope"); |
||||
|
} |
||||
|
return [$token['user'], $data]; |
||||
|
} |
||||
|
} |
@ -0,0 +1,10 @@ |
|||||
|
<?php |
||||
|
/** @license MIT |
||||
|
* Copyright 2017 J. King, Dustin Wilson et al. |
||||
|
* See LICENSE and AUTHORS files for details */ |
||||
|
|
||||
|
declare(strict_types=1); |
||||
|
namespace JKingWeb\Arsse\REST\Microsub; |
||||
|
|
||||
|
class ExceptionAuth extends \Exception { |
||||
|
} |
@ -0,0 +1,313 @@ |
|||||
|
<?php |
||||
|
/** @license MIT |
||||
|
* Copyright 2017 J. King, Dustin Wilson et al. |
||||
|
* See LICENSE and AUTHORS files for details */ |
||||
|
|
||||
|
declare(strict_types=1); |
||||
|
namespace JKingWeb\Arsse\TestCase\REST\Microsub; |
||||
|
|
||||
|
use JKingWeb\Arsse\Arsse; |
||||
|
use JKingWeb\Arsse\Database; |
||||
|
use JKingWeb\Arsse\Db\ExceptionInput; |
||||
|
use JKingWeb\Arsse\REST\Microsub\Auth; |
||||
|
use JKingWeb\Arsse\REST\Microsub\ExceptionAuth; |
||||
|
use Psr\Http\Message\ResponseInterface; |
||||
|
use Laminas\Diactoros\Response\JsonResponse as Response; |
||||
|
use Laminas\Diactoros\Response\EmptyResponse; |
||||
|
use Laminas\Diactoros\Response\HtmlResponse; |
||||
|
|
||||
|
/** @covers \JKingWeb\Arsse\REST\Microsub\Auth<extended> */ |
||||
|
class TestAuth extends \JKingWeb\Arsse\Test\AbstractTest { |
||||
|
public function setUp(): void { |
||||
|
self::clearData(); |
||||
|
$this->dbMock = $this->mock(Database::class); |
||||
|
} |
||||
|
|
||||
|
public function req(string $url, string $method = "GET", array $params = [], array $headers = [], array $data = [], string $type = "application/x-www-form-urlencoded", string $body = null, string $user = null): ResponseInterface { |
||||
|
Arsse::$db = $this->dbMock->get(); |
||||
|
$type = (strtoupper($method) === "GET") ? "" : $type; |
||||
|
$req = $this->serverRequest($method, $url, "/u/", $headers, [], $body ?? $data, $type, $params, $user); |
||||
|
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('<meta charset="UTF-8"><link rel="authorization_endpoint" href="'.htmlspecialchars($auth).'"><link rel="token_endpoint" href="'.htmlspecialchars($token).'"><link rel="microsub" href="'.htmlspecialchars($microsub).'">', 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"], |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
/** @dataProvider provideLoginData */ |
||||
|
public function testLogInAUser(array $params, string $authenticatedUser = null, ResponseInterface $exp) { |
||||
|
$this->dbMock->tokenCreate->returns("authCode"); |
||||
|
$act = $this->req("http://example.com/u/?f=auth", "GET", $params, [], [], "", null, $authenticatedUser); |
||||
|
$this->assertMessage($exp, $act); |
||||
|
if ($act->getStatusCode() == 302 && !preg_match("/\berror=\w/", $act->getHeaderLine("Location") ?? "")) { |
||||
|
$this->dbMock->tokenCreate->calledWith($authenticatedUser, "microsub.auth", null, $this->isInstanceOf(\DateTimeInterface::class), json_encode([ |
||||
|
'me' => $params['me'], |
||||
|
'client_id' => $params['client_id'], |
||||
|
'redirect_uri' => $params['redirect_uri'], |
||||
|
'response_type' => strlen($params['response_type'] ?? "") ? $params['response_type'] : "id", |
||||
|
], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)); |
||||
|
} else { |
||||
|
$this->dbMock->tokenCreate->never()->called(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public function provideLoginData() { |
||||
|
return [ |
||||
|
'Challenge' => [['me' => "https://example.com/u/john.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "http://example.org/redirect", 'state' => "ABCDEF", 'response_type' => "code"], null, new EmptyResponse(401)], |
||||
|
'Failed challenge' => [['me' => "https://example.com/u/john.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "http://example.org/redirect", 'state' => "ABCDEF", 'response_type' => "code"], "", new EmptyResponse(401)], |
||||
|
'Wrong user 1' => [['me' => "https://example.com/u/john.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "http://example.org/redirect", 'state' => "ABCDEF", 'response_type' => "code"], "jane.doe", new EmptyResponse(302, ['Location' => "http://example.org/redirect?state=ABCDEF&error=access_denied"])], |
||||
|
'Wrong user 2' => [['me' => "https://example.com/u/jane.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "http://example.org/redirect", 'state' => "ABCDEF", 'response_type' => "code"], "john.doe", new EmptyResponse(302, ['Location' => "http://example.org/redirect?state=ABCDEF&error=access_denied"])], |
||||
|
'Wrong domain 1' => [['me' => "https://example.net/u/john.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "http://example.org/redirect", 'state' => "ABCDEF", 'response_type' => "code"], "john.doe", new EmptyResponse(302, ['Location' => "http://example.org/redirect?state=ABCDEF&error=access_denied"])], |
||||
|
'Wrong domain 2' => [['me' => "https:///u/john.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "http://example.org/redirect", 'state' => "ABCDEF", 'response_type' => "code"], "john.doe", new EmptyResponse(302, ['Location' => "http://example.org/redirect?state=ABCDEF&error=access_denied"])], |
||||
|
'Wrong port' => [['me' => "https://example.com:80/u/john.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "http://example.org/redirect", 'state' => "ABCDEF", 'response_type' => "code"], "john.doe", new EmptyResponse(302, ['Location' => "http://example.org/redirect?state=ABCDEF&error=access_denied"])], |
||||
|
'Wrong scheme' => [['me' => "ftp://example.com/u/john.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "http://example.org/redirect", 'state' => "ABCDEF", 'response_type' => "code"], "john.doe", new EmptyResponse(302, ['Location' => "http://example.org/redirect?state=ABCDEF&error=access_denied"])], |
||||
|
'Wrong path' => [['me' => "http://example.com/user/john.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "http://example.org/redirect", 'state' => "ABCDEF", 'response_type' => "code"], "john.doe", new EmptyResponse(302, ['Location' => "http://example.org/redirect?state=ABCDEF&error=access_denied"])], |
||||
|
'Bad redirect 1' => [['me' => "https://example.com/u/john.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "//example.org/redirect", 'state' => "ABCDEF", 'response_type' => "code"], "john.doe", new EmptyResponse(400)], |
||||
|
'Bad redirect 2' => [['me' => "https://example.com/u/john.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "https:///redirect", 'state' => "ABCDEF", 'response_type' => "code"], "john.doe", new EmptyResponse(400)], |
||||
|
'Bad response type' => [['me' => "https://example.com/u/john.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "http://example.org/redirect", 'state' => "ABCDEF", 'response_type' => "bad"], "john.doe", new EmptyResponse(302, ['Location' => "http://example.org/redirect?state=ABCDEF&error=unsupported_response_type"])], |
||||
|
'Success 1' => [['me' => "https://example.com/u/john.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "http://example.org/redirect", 'state' => "ABCDEF", 'response_type' => "code"], "john.doe", new EmptyResponse(302, ['Location' => "http://example.org/redirect?state=ABCDEF&code=authCode"])], |
||||
|
'Success 2' => [['me' => "https://example.com/u/john.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "http://example.org/redirect", 'state' => "R&R", 'response_type' => "code"], "john.doe", new EmptyResponse(302, ['Location' => "http://example.org/redirect?state=R%26R&code=authCode"])], |
||||
|
'Success 3' => [['me' => "https://example.com/u/john.doe", 'client_id' => "http://example.org/", 'redirect_uri' => "http://example.org/?p=redirect", 'state' => "ABCDEF", 'response_type' => "code"], "john.doe", new EmptyResponse(302, ['Location' => "http://example.org/?p=redirect&state=ABCDEF&code=authCode"])], |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
/** @dataProvider provideAuthData */ |
||||
|
public function testVerifyAnAuthenticationCode(array $params, string $user, $data, ResponseInterface $exp) { |
||||
|
if ($data instanceof \Exception) { |
||||
|
$this->dbMock->tokenLookup->throws($data); |
||||
|
} else { |
||||
|
$this->dbMock->tokenLookup->returns(['user' => $user, 'data' => $data]); |
||||
|
} |
||||
|
$act = $this->req("http://example.com/u/?f=auth", "POST", [], [], $params); |
||||
|
$this->assertMessage($exp, $act); |
||||
|
if ($act->getStatusCode() == 200) { |
||||
|
$this->dbMock->tokenRevoke->calledWith($user, "microsub.auth", $params['code'] ?? ""); |
||||
|
} else { |
||||
|
$this->dbMock->tokenRevoke->never()->called(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public function provideAuthData() { |
||||
|
return [ |
||||
|
'Missing code 1' => [[ 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/"], "someone", '{"redirect_uri":"https://example.org/","client_id":"https://example.net/"}', new Response(['error' => "invalid_request"], 400)], |
||||
|
'Missing code 2' => [['code' => "", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/"], "someone", '{"redirect_uri":"https://example.org/","client_id":"https://example.net/"}', new Response(['error' => "invalid_request"], 400)], |
||||
|
'Missing URL 1' => [['code' => "code", 'client_id' => "https://example.net/"], "someone", '{"redirect_uri":"https://example.org/","client_id":"https://example.net/"}', new Response(['error' => "invalid_request"], 400)], |
||||
|
'Missing URL 2' => [['code' => "code", 'redirect_uri' => "", 'client_id' => "https://example.net/"], "someone", '{"redirect_uri":"https://example.org/","client_id":"https://example.net/"}', new Response(['error' => "invalid_request"], 400)], |
||||
|
'Missing ID 1' => [['code' => "code", 'redirect_uri' => "https://example.org/", ], "someone", '{"redirect_uri":"https://example.org/","client_id":"https://example.net/"}', new Response(['error' => "invalid_request"], 400)], |
||||
|
'Missing ID 2' => [['code' => "code", 'redirect_uri' => "https://example.org/", 'client_id' => "" ], "someone", '{"redirect_uri":"https://example.org/","client_id":"https://example.net/"}', new Response(['error' => "invalid_request"], 400)], |
||||
|
'Mismatched URL' => [['code' => "code", 'redirect_uri' => "https://example.net/", 'client_id' => "https://example.net/"], "someone", '{"redirect_uri":"https://example.org/","client_id":"https://example.net/"}', new Response(['error' => "invalid_client" ], 400)], |
||||
|
'Mismatched ID' => [['code' => "code", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.org/"], "someone", '{"redirect_uri":"https://example.org/","client_id":"https://example.net/"}', new Response(['error' => "invalid_client" ], 400)], |
||||
|
'Bad data 1' => [['code' => "bad-data1", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/"], "someone", null, new Response(['error' => "invalid_grant" ], 400)], |
||||
|
'Bad data 2' => [['code' => "bad-data2", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/"], "someone", '{client_id":"https://example.net/"}', new Response(['error' => "invalid_grant" ], 400)], |
||||
|
'Bad data 3' => [['code' => "bad-data3", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/"], "someone", '{"redirect_uri":"https://example.org/"}', new Response(['error' => "invalid_grant" ], 400)], |
||||
|
'Bad user' => [['code' => "bad-user", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/"], "someone", new ExceptionInput("subjectMissing"), new Response(['error' => "invalid_grant" ], 400)], |
||||
|
'Bad type' => [['code' => "bad-type", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/"], "someone", '{"redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"token"}', new Response(['error' => "invalid_grant" ], 400)], |
||||
|
'Success 1' => [['code' => "valid-code", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/"], "someone", '{"redirect_uri":"https://example.org/","client_id":"https://example.net/"}', new Response(['me' => "http://example.com/u/someone"], 200)], |
||||
|
'Success 2' => [['code' => "good-code", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/"], "somehow", '{"redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"id"}', new Response(['me' => "http://example.com/u/somehow"], 200)], |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
/** @dataProvider provideTokenRequests */ |
||||
|
public function testIssueAnAccessToken(array $params, string $user, $data, ResponseInterface $exp) { |
||||
|
if ($data instanceof \Exception) { |
||||
|
$this->dbMock->tokenLookup->throws($data); |
||||
|
} else { |
||||
|
$this->dbMock->tokenLookup->returns(['user' => $user, 'data' => $data]); |
||||
|
} |
||||
|
$this->dbMock->tokenCreate->returns("TOKEN"); |
||||
|
$act = $this->req("http://example.com/u/?f=token", "POST", [], [], $params); |
||||
|
$this->assertMessage($exp, $act); |
||||
|
if ($act->getStatusCode() == 200) { |
||||
|
$input = '{"me":"'.($params['me'] ?? "").'","client_id":"'.($params['client_id'] ?? "").'"}'; |
||||
|
$this->dbMock->tokenCreate->calledWith($user, "microsub.access", null, null, $input); |
||||
|
$this->dbMock->tokenRevoke->calledWith($user, "microsub.auth", $params['code'] ?? ""); |
||||
|
} else { |
||||
|
$this->dbMock->tokenCreate->never()->called(); |
||||
|
$this->dbMock->tokenRevoke->never()->called(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public function provideTokenRequests() { |
||||
|
$scopes = implode(" ", Auth::SCOPES); |
||||
|
return [ |
||||
|
'Missing code 1' => [[ 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "invalid_request" ], 400)], |
||||
|
'Missing code 2' => [['code' => "", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "invalid_request" ], 400)], |
||||
|
'Missing URL 1' => [['code' => "code", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "invalid_request" ], 400)], |
||||
|
'Missing URL 2' => [['code' => "code", 'redirect_uri' => "", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "invalid_request" ], 400)], |
||||
|
'Missing ID 1' => [['code' => "code", 'redirect_uri' => "https://example.org/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "invalid_request" ], 400)], |
||||
|
'Missing ID 2' => [['code' => "code", 'redirect_uri' => "https://example.org/", 'client_id' => "", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "invalid_request" ], 400)], |
||||
|
'Missing grant 1' => [['code' => "code", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "unsupported_grant_type"], 400)], |
||||
|
'Missing grant 2' => [['code' => "code", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "unsupported_grant_type"], 400)], |
||||
|
'Missing me 1' => [['code' => "code", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "" ], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "invalid_request" ], 400)], |
||||
|
'Missing me 2' => [['code' => "code", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", ], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "invalid_request" ], 400)], |
||||
|
'Mismatched URL' => [['code' => "code", 'redirect_uri' => "https://example.net/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "invalid_client" ], 400)], |
||||
|
'Mismatched ID' => [['code' => "code", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.org/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "invalid_client" ], 400)], |
||||
|
'Mismatched grant' => [['code' => "code", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "mismatch", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "unsupported_grant_type"], 400)], |
||||
|
'Mismatched me' => [['code' => "code", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/" ], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "invalid_grant" ], 400)], |
||||
|
'Bad data 1' => [['code' => "bad-data1", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", null, new Response(['error' => "invalid_grant" ], 400)], |
||||
|
'Bad data 2' => [['code' => "bad-data2", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", '{ "redirect_uri":"https://example.org/", client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "invalid_grant" ], 400)], |
||||
|
'Bad data 3' => [['code' => "bad-data3", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone", client_id":"https://example.net/","response_type":"code"}', new Response(['error' => "invalid_grant" ], 400)], |
||||
|
'Bad data 4' => [['code' => "bad-data4", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/", "response_type":"code"}', new Response(['error' => "invalid_grant" ], 400)], |
||||
|
'Bad data 5' => [['code' => "bad-data5", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/", }', new Response(['error' => "invalid_grant" ], 400)], |
||||
|
'Bad data 6' => [['code' => "bad-data6", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"id" }', new Response(['error' => "invalid_grant" ], 400)], |
||||
|
'Bad user' => [['code' => "bad-user", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", new ExceptionInput("subjectMissing"), new Response(['error' => "invalid_grant" ], 400)], |
||||
|
'Success 1' => [['code' => "valid-code", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/someone"], "someone", '{"me":"https://example.com/u/someone","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['me' => "http://example.com/u/someone", 'token_type' => "Bearer", 'access_token' => "TOKEN", 'scope' => $scopes], 200)], |
||||
|
'Success 2' => [['code' => "good-code", 'redirect_uri' => "https://example.org/", 'client_id' => "https://example.net/", 'grant_type' => "authorization_code", 'me' => "https://example.com/u/somehow"], "somehow", '{"me":"https://example.com/u/somehow","redirect_uri":"https://example.org/","client_id":"https://example.net/","response_type":"code"}', new Response(['me' => "http://example.com/u/somehow", 'token_type' => "Bearer", 'access_token' => "TOKEN", 'scope' => $scopes], 200)], |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
/** @dataProvider provideBearers */ |
||||
|
public function testLogInABearer(string $authorization, array $scopes, string $token, string $user, $data, $exp) { |
||||
|
if ($data instanceof \Exception) { |
||||
|
$this->dbMock->tokenLookup->throws($data); |
||||
|
} else { |
||||
|
$this->dbMock->tokenLookup->returns(['user' => $user, 'data' => $data]); |
||||
|
} |
||||
|
Arsse::$db = $this->dbMock->get(); |
||||
|
if ($exp instanceof \Exception) { |
||||
|
$this->assertException($exp); |
||||
|
} |
||||
|
try { |
||||
|
$act = Auth::validateBearer($authorization, $scopes); |
||||
|
$this->assertSame($exp, $act); |
||||
|
} finally { |
||||
|
if (strlen($token)) { |
||||
|
$this->dbMock->tokenLookup->calledWith("microsub.access", $token); |
||||
|
} else { |
||||
|
$this->dbMock->tokenLookup->never()->called(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public function provideBearers() { |
||||
|
return [ |
||||
|
'Not a bearer' => ["Beaver TOKEN", [], "", "", "", new ExceptionAuth("invalid_request")], |
||||
|
'Token missing 1' => ["Bearer", [], "", "", "", new ExceptionAuth("invalid_request")], |
||||
|
'Token missing 2' => ["Bearer ", [], "", "", "", new ExceptionAuth("invalid_request")], |
||||
|
'Not a token' => ["Bearer !", [], "", "", "", new ExceptionAuth("invalid_request")], |
||||
|
'Invalid token' => ["Bearer TOKEN", [], "TOKEN", "", new ExceptionInput("subjectMissing"), new ExceptionAuth("invalid_token")], |
||||
|
'Insufficient scope 1' => ["Bearer TOKEN", ["missing"], "TOKEN", "someone", null, new ExceptionAuth("insufficient_scope")], |
||||
|
'Insufficient scope 2' => ["Bearer TOKEN", ["channels"], "TOKEN", "someone", '{"scope":["read","follow"]}', new ExceptionAuth("insufficient_scope")], |
||||
|
'Success 1' => ["Bearer TOKEN", [], "TOKEN", "someone", null, ["someone", ['scope' => Auth::SCOPES]]], |
||||
|
'Success 2' => ["bearer TOKEN", [], "TOKEN", "someone", null, ["someone", ['scope' => Auth::SCOPES]]], |
||||
|
'Success 3' => ["BEARER TOKEN", [], "TOKEN", "someone", null, ["someone", ['scope' => Auth::SCOPES]]], |
||||
|
'Broken data' => ["Bearer TOKEN", [], "TOKEN", "someone", '{', ["someone", ['scope' => Auth::SCOPES]]], |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
/** @dataProvider provideRevocations */ |
||||
|
public function testRevokeAToken(array $params, $user, ResponseInterface $exp) { |
||||
|
$this->dbMock->tokenRevoke->returns(true); |
||||
|
if ($user instanceof \Exception) { |
||||
|
$this->dbMock->tokenLookup->throws($user); |
||||
|
} else { |
||||
|
$this->dbMock->tokenLookup->returns(['user' => $user]); |
||||
|
} |
||||
|
$this->assertMessage($exp, $this->req("http://example.com/u/?f=token", "POST", [], [], array_merge(['action' => "revoke"], $params))); |
||||
|
$doLookup = strlen($params['token'] ?? "") > 0; |
||||
|
$doRevoke = ($doLookup && !$user instanceof \Exception); |
||||
|
if ($doLookup) { |
||||
|
$this->dbMock->tokenLookup->calledWith("microsub.access", $params['token'] ?? ""); |
||||
|
} else { |
||||
|
$this->dbMock->tokenLookup->never()->called(); |
||||
|
} |
||||
|
if ($doRevoke) { |
||||
|
$this->dbMock->tokenRevoke->calledWith($user, "microsub.access", $params['token'] ?? ""); |
||||
|
} else { |
||||
|
$this->dbMock->tokenRevoke->never()->called(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public function provideRevocations() { |
||||
|
return [ |
||||
|
'Missing token 1' => [[], "", new EmptyResponse(422)], |
||||
|
'Missing token 2' => [['token' => ""], "", new EmptyResponse(422)], |
||||
|
'Bad Token' => [['token' => "bad"], new ExceptionInput("subjectMissing"), new EmptyResponse(200)], |
||||
|
'Success' => [['token' => "good"], "someone", new EmptyResponse(200)], |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
/** @dataProvider provideTokenVerifications */ |
||||
|
public function testVerifyAToken(array $authorization, $output, ResponseInterface $exp) { |
||||
|
if ($output instanceof \Exception) { |
||||
|
$this->dbMock->tokenLookup->throws($output); |
||||
|
} else { |
||||
|
$this->dbMock->tokenLookup->returns(['user' => "someone", 'data' => $output]); |
||||
|
} |
||||
|
$this->assertMessage($exp, $this->req("http://example.com/u/?f=token", "GET", [], $authorization ? ['Authorization' => $authorization] : [])); |
||||
|
$this->dbMock->tokenRevoke->never()->called(); |
||||
|
} |
||||
|
|
||||
|
public function provideTokenVerifications() { |
||||
|
return [ |
||||
|
'No credentials' => [[], "", new EmptyResponse(401, ['WWW-Authenticate' => 'Bearer error="invalid_token"', 'X-Arsse-Suppress-General-Auth' => "1"])], |
||||
|
'Too many credentials' => [["Bearer TOKEN", "Basic BASE64"], "", new EmptyResponse(400, ['WWW-Authenticate' => 'Bearer error="invalid_request"'])], |
||||
|
'Invalid credentials' => [["Bearer !"], "", new EmptyResponse(400, ['WWW-Authenticate' => 'Bearer error="invalid_request"'])], |
||||
|
'Bad credentials' => [["Bearer BAD"], new ExceptionInput("subjectMissing"), new EmptyResponse(401, ['WWW-Authenticate' => 'Bearer error="invalid_token"', 'X-Arsse-Suppress-General-Auth' => "1"])], |
||||
|
'Success 1' => [["Bearer GOOD"], '{"me":"ook","client_id":"eek","scope":["ack"]}', new Response(['me' => "ook", 'client_id' => "eek", 'scope' => "ack"])], |
||||
|
'Success 2' => [["Bearer GOOD"], '{"scope":["ook","eek","ack"]}', new Response(['me' => "", 'client_id' => "", 'scope' => "ook eek ack"])], |
||||
|
'Success 3' => [["Bearer GOOD"], '{}', new Response(['me' => "", 'client_id' => "", 'scope' => "read follow channels"])], |
||||
|
]; |
||||
|
} |
||||
|
} |
Loading…
Reference in new issue