Compare commits

...

34 Commits

Author SHA1 Message Date
J. King e4fdbc454f Update Microsub for Laminas and Phony migrations 3 years ago
J. King 4a0face9af Merge branch 'master' into microsub 3 years ago
J. King 8d1451d26c Merge branch 'master' into microsub 4 years ago
J. King f46d053428 Last set of tests for IndieAuth 4 years ago
J. King f13366121f Tests for logging in a bearer 4 years ago
J. King 2d78a59603 Fix identifier construction 4 years ago
J. King 1073707f9c First set of tests for token issuance 4 years ago
J. King 3f31ae1a57 Merge branch 'master' into microsub 4 years ago
J. King 1e94d102e5 Merge branch 'master' into microsub 4 years ago
J. King 9d28dd5051 Tests for auth code verification 4 years ago
J. King 38a2776ae9 Merge branch 'master' into microsub 5 years ago
J. King a2468b85fa Don't initialize Conf in data providers 5 years ago
J. King 2e7fa7cd1d Merge branch 'master' into microsub 5 years ago
J. King c0f42ac031 Second set of authentication tests 5 years ago
J. King ba17d16358 Use media type matcher in IndieAuth 5 years ago
J. King cb49d810ee Merge branch 'master' into microsub 5 years ago
J. King 29fb8b9ea7 Stop REST class adding Basic auth for token checks 5 years ago
J. King 94bf37c388 Authentication tests 5 years ago
J. King 26fa9461eb First battery of IndieAuth tests, with fixes 5 years ago
J. King 6d2b587e38 Merge branch 'master' into microsub 5 years ago
J. King 7958cc6f62 Test skeleton for IndieAuth 5 years ago
J. King cbd382d768 Token verification and revocation 5 years ago
J. King 73a27728a1 Bearer token validation 5 years ago
J. King e1318ee736 Bearer validation 5 years ago
J. King e6482bb8aa Refactor auth code verification some more, and fix token issuance 5 years ago
J. King 7a337d7d62 Refactor of IndieAuth implementation 5 years ago
J. King a81f2897c8 Basic token issuance 5 years ago
J. King c814ce1791 Handle errors better 5 years ago
J. King daab0068d6 Auth code verification and general reorganization 5 years ago
J. King 8308fbad31 Documentation and auth code client ID tracking 5 years ago
J. King 1b149e770c Add token data to database 5 years ago
J. King dd3e143212 Complete IndieAuth authorizer 5 years ago
J. King d8c484d387 Partial implementation of IndieAuth authorization 5 years ago
J. King 02330759b4 Implement IndieAuth discovery 5 years ago
  1. 19
      docs/en/030_Supported_Protocols/040_Microsub.md
  2. 9
      lib/Misc/URL.php
  3. 29
      lib/REST.php
  4. 391
      lib/REST/Microsub/Auth.php
  5. 10
      lib/REST/Microsub/ExceptionAuth.php
  6. 1
      tests/cases/Misc/TestURL.php
  7. 313
      tests/cases/REST/Microsub/TestAuth.php
  8. 25
      tests/cases/REST/TestREST.php
  9. 30
      tests/lib/AbstractTest.php
  10. 3
      tests/phpunit.dist.xml

19
docs/en/030_Supported_Protocols/040_Microsub.md

@ -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.

9
lib/Misc/URL.php

@ -6,6 +6,8 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\Misc;
use function GuzzleHttp\Psr7\parse_header;
/**
* A collection of functions for manipulating URLs
*/
@ -37,7 +39,12 @@ class URL {
* @param string $p Password to add to the URL, if a username is specified
*/
public static function normalize(string $url, string $u = null, string $p = null): string {
extract(parse_url($url));
$parts = parse_url($url);
if (!$parts) {
// bail if there is no authority
return $url;
}
extract($parts);
$out = "";
if (isset($scheme)) {
$out .= strtolower($scheme).":";

29
lib/REST.php

@ -6,6 +6,7 @@
declare(strict_types=1);
namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\Misc\URL;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ServerRequestInterface;
@ -26,19 +27,29 @@ class REST {
'class' => REST\NextcloudNews\V1_2::class,
],
'ttrss_api' => [ // Tiny Tiny RSS https://git.tt-rss.org/git/tt-rss/wiki/ApiReference
'match' => '/tt-rss/api',
'strip' => '/tt-rss/api',
'match' => "/tt-rss/api",
'strip' => "/tt-rss/api",
'class' => REST\TinyTinyRSS\API::class,
],
'ttrss_icon' => [ // Tiny Tiny RSS feed icons
'match' => '/tt-rss/feed-icons/',
'strip' => '/tt-rss/feed-icons/',
'match' => "/tt-rss/feed-icons/",
'strip' => "/tt-rss/feed-icons/",
'class' => REST\TinyTinyRSS\Icon::class,
],
'fever' => [ // Fever https://web.archive.org/web/20161217042229/https://feedafever.com/api
'match' => '/fever/',
'strip' => '/fever/',
'match' => "/fever/",
'strip' => "/fever/",
'class' => REST\Fever\API::class,
],/*
'microsub' => [ // Microsub https://indieweb.org/Microsub
'match' => "/microsub",
'strip' => "",
'class' => REST\Microsub\API::class,
],*/
'microsub_auth' => [ // IndieAuth for Microsub https://indieauth.spec.indieweb.org/
'match' => "/u/",
'strip' => "/u/",
'class' => REST\Microsub\Auth::class,
],
'miniflux' => [ // Miniflux https://miniflux.app/docs/api.html
'match' => '/v1/',
@ -56,7 +67,6 @@ class REST {
'class' => REST\Miniflux\Status::class,
],
// Other candidates:
// Microsub https://indieweb.org/Microsub
// Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html
// Feedbin v2 https://github.com/feedbin/feedbin-api
// CommaFeed https://www.commafeed.com/api/
@ -160,7 +170,10 @@ class REST {
public function challenge(ResponseInterface $res, string $realm = null): ResponseInterface {
$realm = $realm ?? Arsse::$conf->httpRealm;
return $res->withAddedHeader("WWW-Authenticate", 'Basic realm="'.$realm.'", charset="UTF-8"');
if (!ValueInfo::normalize($res->getHeaderLine("X-Arsse-Suppress-General-Auth"), ValueInfo::T_BOOL)) {
$res = $res->withAddedHeader("WWW-Authenticate", 'Basic realm="'.$realm.'", charset="UTF-8"');
}
return $res->withoutHeader("X-Arsse-Suppress-General-Auth");
}
public function normalizeResponse(ResponseInterface $res, RequestInterface $req = null): ResponseInterface {

391
lib/REST/Microsub/Auth.php

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

10
lib/REST/Microsub/ExceptionAuth.php

@ -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 {
}

1
tests/cases/Misc/TestURL.php

@ -70,6 +70,7 @@ class TestURL extends \JKingWeb\Arsse\Test\AbstractTest {
["EXAMPLE.COM/", "EXAMPLE.COM/"],
["EXAMPLE.COM", "EXAMPLE.COM"],
[" ", "%20"],
["http:///%G", "http:///%G"]
];
}

313
tests/cases/REST/Microsub/TestAuth.php

@ -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"])],
];
}
}

25
tests/cases/REST/TestREST.php

@ -92,19 +92,26 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
[[], []],
];
}
public function testSendAuthenticationChallenges(): void {
/** @dataProvider provideAuthenticationChallenges */
public function testSendAuthenticationChallenges(ResponseInterface $in, ResponseInterface $exp, string $realm = null) {
self::setConf();
$r = new REST();
$in = new EmptyResponse(401);
$exp = $in->withHeader("WWW-Authenticate", 'Basic realm="OOK", charset="UTF-8"');
$act = $r->challenge($in, "OOK");
$this->assertMessage($exp, $act);
$exp = $in->withHeader("WWW-Authenticate", 'Basic realm="'.Arsse::$conf->httpRealm.'", charset="UTF-8"');
$act = $r->challenge($in);
$act = (new REST)->challenge($in, $realm);
$this->assertMessage($exp, $act);
}
public function provideAuthenticationChallenges() {
$realm = (new \ReflectionClass(\JKingWeb\Arsse\Conf::class))->getDefaultProperties()['httpRealm'];
$default = 'Basic realm="'.$realm.'", charset="UTF-8"';
return [
[new EmptyResponse(401), new EmptyResponse(401, ['WWW-Authenticate' => $default])],
[new EmptyResponse(401), new EmptyResponse(401, ['WWW-Authenticate' => 'Basic realm="OOK", charset="UTF-8"']), "OOK"],
[new EmptyResponse(401, ['WWW-Authenticate' => "Bearer"]), new EmptyResponse(401, ['WWW-Authenticate' => ['Bearer', $default]])],
[new EmptyResponse(401, ['X-Arsse-Suppress-General-Auth' => "false"]), new EmptyResponse(401, ['WWW-Authenticate' => $default])],
[new EmptyResponse(401, ['WWW-Authenticate' => "Bearer", 'X-Arsse-Suppress-General-Auth' => "false"]), new EmptyResponse(401, ['WWW-Authenticate' => ['Bearer', $default]])],
[new EmptyResponse(401, ['WWW-Authenticate' => "Bearer", 'X-Arsse-Suppress-General-Auth' => "1"]), new EmptyResponse(401, ['WWW-Authenticate' => "Bearer"])],
];
}
/** @dataProvider provideUnnormalizedOrigins */
public function testNormalizeOrigins(string $origin, string $exp, array $ports = null): void {
$r = new REST();

30
tests/lib/AbstractTest.php

@ -6,6 +6,7 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\Test;
use JKingWeb\Arsse\AbstractException;
use Eloquent\Phony\Mock\Handle\InstanceHandle;
use Eloquent\Phony\Phpunit\Phony;
use GuzzleHttp\Exception\GuzzleException;
@ -85,6 +86,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
if (strlen($type)) {
$server['HTTP_CONTENT_TYPE'] = $type;
}
// add any specified parameters to the URL
if (isset($params)) {
if (is_array($params)) {
$params = implode("&", array_map(function($v, $k) {
@ -94,12 +96,27 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
$url = URL::queryAppend($url, (string) $params);
$params = null;
}
// glean the scheme, hostname, and port from the URL
$urlParts = parse_url($url);
if (isset($urlParts['scheme'])) {
$server['HTTPS'] = strtolower($urlParts['scheme']) === "https" ? "on" : "off";
}
if (isset($urlParts['host'])) {
$server['HTTP_HOST'] = $urlParts['host'];
if (isset($urlParts['port'])) {
$server['SERVER_PORT'] = $urlParts['port'];
}
$url = $urlParts['path'].(isset($urlParts['query']) ? "?".$urlParts['query'] : "");
}
$server['REQUEST_URI'] = $url;
// rebuild the parsed query parameters from the URL
$q = parse_url($url, \PHP_URL_QUERY);
if (strlen($q ?? "")) {
parse_str($q, $params);
} else {
$params = [];
}
// prepare the body and parsed body
$parsedBody = null;
if (isset($body)) {
if (is_string($body) && in_array(strtolower($type), ["", "application/x-www-form-urlencoded"])) {
@ -111,8 +128,12 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
$body = http_build_query($body, "a", "&");
}
}
// add any override values to the $_SERVER array
$server = array_merge($server, $vars);
// create the request
$req = new ServerRequest($server, [], $url, $method, "php://memory", [], [], $params, $parsedBody);
// if a user is specified, act as if they were authenticated by the global handler
// the empty string denotes failed authentication
if (isset($user)) {
if (strlen($user)) {
$req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", $user);
@ -123,6 +144,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
if (strlen($type) && strlen($body ?? "")) {
$req = $req->withHeader("Content-Type", $type);
}
// add any other headers
foreach ($headers as $key => $value) {
if (!is_null($value)) {
$req = $req->withHeader($key, $value);
@ -130,13 +152,16 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
$req = $req->withoutHeader($key);
}
}
// strip the URL prefix from the request target, as the global handler does
$target = substr(URL::normalize($url), strlen($urlPrefix));
$req = $req->withRequestTarget($target);
// add the body text, if any
if (strlen($body ?? "")) {
$p = $req->getBody();
$p->write($body);
$req = $req->withBody($p);
}
// return the prepared request
return $req;
}
@ -158,9 +183,12 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
public function assertException($msg = "", string $prefix = "", string $type = "Exception"): void {
if (func_num_args()) {
if ($msg instanceof \JKingWeb\Arsse\AbstractException) {
if ($msg instanceof \Exception) {
$this->expectException(get_class($msg));
$this->expectExceptionCode($msg->getCode());
if (!$msg instanceof AbstractException && strlen($msg->getMessage())) {
$this->expectExceptionMessage($msg->getMessage());
}
} else {
$class = \JKingWeb\Arsse\NS_BASE.($prefix !== "" ? str_replace("/", "\\", $prefix)."\\" : "").$type;
$msgID = ($prefix !== "" ? $prefix."/" : "").$type.".$msg";

3
tests/phpunit.dist.xml

@ -138,6 +138,9 @@
<file>cases/REST/Fever/TestAPI.php</file>
<file>cases/REST/Fever/PDO/TestAPI.php</file>
</testsuite>
<testsuite name="Microsub">
<file>cases/REST/Microsub/TestAuth.php</file>
</testsuite>
<testsuite name="Admin tools">
<file>cases/Service/TestService.php</file>
<file>cases/Service/TestSerial.php</file>

Loading…
Cancel
Save