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