This repository has been archived on 2023-08-20. You can view files and clone it, but cannot push or open issues or pull requests.
indieauth/tests/ServerTest.php

444 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php declare(strict_types=1);
namespace Taproot\IndieAuth\Test;
use DirectoryIterator;
use Exception;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use Nyholm\Psr7\Request;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Taproot\IndieAuth\Callback\DefaultAuthorizationForm;
use Taproot\IndieAuth\Callback\SingleUserPasswordAuthenticationCallback;
use Taproot\IndieAuth\IndieAuthException;
use Taproot\IndieAuth\Server;
use Taproot\IndieAuth\Storage\FilesystemJsonStorage;
use function Taproot\IndieAuth\hashAuthorizationRequestParameters;
use function Taproot\IndieAuth\urlComponentsMatch;
const SERVER_SECRET = '1111111111111111111111111111111111111111111111111111111111111111';
const TOKEN_STORAGE_PATH = __DIR__ . '/tmp/tokens';
const CODE_EXCEPTION_TEMPLATE_PATH = __DIR__ . '/templates/code_exception_response.txt.php';
const AUTHORIZATION_FORM_JSON_RESPONSE_TEMPLATE_PATH = __DIR__ . '/templates/authorization_form_json_response.json.php';
const TMP_DIR = __DIR__ . '/tmp';
class ServerTest extends TestCase {
protected function getDefaultServer(array $config=[]) {
return new Server(array_merge([
'secret' => SERVER_SECRET,
'tokenStorage' => TOKEN_STORAGE_PATH,
// With this template, IndieAuthException response bodies will contain only their IndieAuthException error code, for ease of comparison.
'exceptionTemplatePath' => CODE_EXCEPTION_TEMPLATE_PATH,
// Default to a simple single-user password authentication handler.
Server::HANDLE_AUTHENTICATION_REQUEST => new SingleUserPasswordAuthenticationCallback(['me' => 'https://example.com/'], password_hash('password', PASSWORD_DEFAULT), Server::DEFAULT_CSRF_KEY),
'authorizationForm' => new DefaultAuthorizationForm(AUTHORIZATION_FORM_JSON_RESPONSE_TEMPLATE_PATH),
], $config));
}
protected function getIARequest(array $params=[]): ServerRequestInterface {
return (new ServerRequest('GET', 'https://example.com/'))->withQueryParams(array_merge([
'response_type' => 'code',
'client_id' => 'https://app.example.com/',
'redirect_uri' => 'https://app.example.com/indieauth',
'state' => '12345',
'code_challenge' => hash('sha256', 'code'),
'code_challenge_method' => 'sha256'
], $params));
}
protected function getApprovalRequest(bool $validCsrf=false, bool $addValidHash=false, ?array $queryParams=null, ?array $parsedBody=null): ServerRequestInterface {
$queryParams = $queryParams ?? [];
$parsedBody = $parsedBody ?? [];
$cookieParams = [];
$parsedBody[Server::APPROVE_ACTION_KEY] = Server::APPROVE_ACTION_VALUE;
// Assume Middleware\DoubleSubmitCookieCsrfMiddleware is being used.
$csrfVal = 'random_and_secure_csrf_value';
if ($validCsrf) {
$parsedBody[Server::DEFAULT_CSRF_KEY] = $csrfVal;
$cookieParams = [
Server::DEFAULT_CSRF_KEY => $csrfVal
];
}
$req = $this->getIARequest($queryParams)
->withMethod('POST')
->withParsedBody($parsedBody)
->withCookieParams($cookieParams);
if ($addValidHash) {
$req = $req->withQueryParams(array_merge($req->getQueryParams(), [
Server::HASH_QUERY_STRING_KEY => hashAuthorizationRequestParameters($req, SERVER_SECRET)
]));
}
return $req;
}
protected function setUp(): void {
// Clean up tmp folder.
@mkdir(TOKEN_STORAGE_PATH, 0777, true);
foreach (new DirectoryIterator(TOKEN_STORAGE_PATH) as $fileInfo) {
if ($fileInfo->isFile()) {
unlink($fileInfo->getPathname());
}
}
@rmdir(TOKEN_STORAGE_PATH);
}
protected function tearDown(): void {
// Clean up tmp folder.
@mkdir(TOKEN_STORAGE_PATH, 0777, true);
foreach (new DirectoryIterator(TOKEN_STORAGE_PATH) as $fileInfo) {
if ($fileInfo->isFile()) {
unlink($fileInfo->getPathname());
}
}
@rmdir(TOKEN_STORAGE_PATH);
}
public function testAuthorizationRequestMissingParametersReturnsError() {
$s = $this->getDefaultServer();
$req = (new ServerRequest('GET', 'https://example.com/'))->withQueryParams([
'response_type' => 'code' // This param is required to identify the request as an IA authorization request.
]);
$res = $s->handleAuthorizationEndpointRequest($req);
$this->assertEquals((string) IndieAuthException::REQUEST_MISSING_PARAMETER, (string) $res->getBody());
}
public function testUnauthenticatedRequestReturnsAuthenticationResponse() {
$expectedResponse = 'You need to authenticate before continuing!';
$s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) use ($expectedResponse) {
return new Response(200, ['content-type' => 'text/plain'], $expectedResponse);
}
]);
$res = $s->handleAuthorizationEndpointRequest($this->getIARequest());
$this->assertEquals(200, $res->getStatusCode());
$this->assertEquals($expectedResponse, (string) $res->getBody());
}
public function testReturnsServerErrorIfAuthenticationResultHasNoMeKey() {
$s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) {
return [];
}
]);
$res = $s->handleAuthorizationEndpointRequest($this->getIARequest());
$this->assertEquals((string) IndieAuthException::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM, (string) $res->getBody());
}
public function testReturnErrorIfFetchingClientIdThrowsException() {
$exceptionClasses = [
'GuzzleHttp\Exception\ConnectException' => (string) IndieAuthException::HTTP_EXCEPTION_FETCHING_CLIENT_ID,
'GuzzleHttp\Exception\RequestException' => (string) IndieAuthException::HTTP_EXCEPTION_FETCHING_CLIENT_ID,
'Exception' => (string) IndieAuthException::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID
];
foreach ($exceptionClasses as $eClass => $expectedResponse) {
$s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) {
return ['me' => 'https://example.com/'];
},
'httpGetWithEffectiveUrl' => function ($url) use ($eClass) {
if ($eClass == 'Exception') { throw new Exception(); }
throw new $eClass($eClass, new Request('GET', $url));
}
]);
$res = $s->handleAuthorizationEndpointRequest($this->getIARequest());
$this->assertEquals($expectedResponse, (string) $res->getBody());
}
}
public function testReturnsErrorIfApprovalRequestHasNoHash() {
$s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) {
return ['me' => 'https://example.com'];
}
]);
$res = $s->handleAuthorizationEndpointRequest($this->getApprovalRequest(true, false));
$this->assertEquals((string) IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH, (string) $res->getBody());
}
public function testReturnsErrorIfApprovalRequestHasInvalidHash() {
$s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) {
return ['me' => 'https://example.com'];
}
]);
$req = $this->getApprovalRequest(true, false);
$req = $req->withQueryParams(array_merge($req->getQueryParams(), [
Server::HASH_QUERY_STRING_KEY => 'clearly not a valid hash'
]));
$res = $s->handleAuthorizationEndpointRequest($req);
$this->assertEquals((string) IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH, (string) $res->getBody());
}
public function testValidApprovalRequestIsHandledCorrectly() {
// Make a valid authentication response with additional information, to make sure that its saved
// in the authorization code.
$authenticationResponse = [
'me' => 'https://me.example.com/',
'profile' => [
'name' => 'Example Name'
]
];
$s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) use ($authenticationResponse) {
return $authenticationResponse;
}
]);
// Make an approval request with valid CSRF tokens, a valid query parameter hash, one requested scope
// (different from the two granted scopes, so that we can test that requested and granted scopes are
// stored separately) and a redirect URI with a query string (so we can test that our IA query string
// parameters are appended correctly).
$grantedScopes = ['create', 'update'];
$req = $this->getApprovalRequest(true, true, [
'scope' => 'create',
'redirect_uri' => 'https://app.example.com/indieauth?client_redirect_query_string_param=value'
], [
'taproot_indieauth_server_scope[]' => $grantedScopes
]);
$res = $s->handleAuthorizationEndpointRequest($req);
$this->assertEquals(302, $res->getStatusCode(), 'The Response from a successful approval request must be a 302 redirect.');
$responseLocation = $res->getHeaderLine('location');
$queryParams = $req->getQueryParams();
parse_str(parse_url($responseLocation, PHP_URL_QUERY), $redirectUriQueryParams);
$this->assertTrue(urlComponentsMatch($responseLocation, $queryParams['redirect_uri'], [PHP_URL_SCHEME, PHP_URL_HOST, PHP_URL_USER, PHP_URL_PORT, PHP_URL_HOST, PHP_URL_PORT, PHP_URL_PATH]), 'The successful redirect response location did not match the redirect URI up to the path.');
$this->assertEquals($redirectUriQueryParams['state'], $queryParams['state'], 'The redirect URI state parameter did not match the authorization request state parameter.');
$this->assertEquals('value', $redirectUriQueryParams['client_redirect_query_string_param'], 'Query string params in the client app redirect_uri were not correctly preserved.');
$storage = new FilesystemJsonStorage(TOKEN_STORAGE_PATH, SECRET);
$storedCode = $storage->get(hash_hmac('sha256', $redirectUriQueryParams['code'], SECRET));
$this->assertNotNull($storedCode, 'An authorization code should be stored after a successful approval request.');
foreach (['client_id', 'redirect_uri', 'state', 'code_challenge', 'code_challenge_method'] as $p) {
$this->assertEquals($queryParams[$p], $storedCode[$p], "Parameter $p in the stored code ({$storedCode[$p]}) was not the same as the request parameter ($queryParams[$p]).");
}
$this->assertTrue(scopeEquals($queryParams['scope'], $storedCode['requested_scope']), "The requested scopes in the stored code ({$storedCode['requested_scope']}) did not match the scopes in the scope query parameter ({$queryParams['scope']}).");
$this->assertTrue(scopeEquals($grantedScopes, $storedCode['scope']), "The granted scopes in the stored code ({$storedCode['scope']}) did not match the granted scopes from the authorization form response (" . join(' ', $grantedScopes) . ").");
$this->assertEquals($authenticationResponse['me'], $storedCode['me'], "The “me” value in the stored code ({$storedCode['me']}) did not match the “me” value from the authentication response ({$authenticationResponse['me']}).");
$this->assertEquals($authenticationResponse['profile'], $storedCode['profile'], "The “profile” value in the stored code did not match the “profile” value from the authentication response.");
}
public function testReturnsErrorIfRedirectUriDoesntMatchClientIdWithNoParsedRedirectUris() {
$s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array {
return ['me' => 'https://me.example.com'];
},
'httpGetWithEffectiveUrl' => function ($url): array {
// An empty response suffices for this test.
return [
new Response(200, ['content-type' => 'text/html'], '' ),
$url
];
}
]);
$req = $this->getIARequest([
'client_id' => 'https://client.example.com/',
'redirect_uri' => 'https://not-the-client.example.com/auth'
]);
$res = $s->handleAuthorizationEndpointRequest($req);
$this->assertEquals((string) IndieAuthException::INVALID_REDIRECT_URI, (string) $res->getBody());
}
public function testReturnsErrorIfRedirectUriDoesntMatchClientIdOrParsedRedirectUris() {
$s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array {
return ['me' => 'https://me.example.com'];
},
'httpGetWithEffectiveUrl' => function ($url): array {
// Pass some tricky values to test for correct rel parsing.
return [
new Response(200, [
'content-type' => 'text/html',
'link' => [
'<https://not-the-client.example.com/auth>; rel="wrong_redirect_uri_rel"', // Matches redirect_uri but has wrong rel
'<https://invalid.example.com/redirect>; rel="redirect_uri"' // redirect_uri is correct but url is invalid.
]
],
<<<EOT
Rel correct, href not: <link rel="redirect_uri" href="https://yet-another-invalid.example.com/redirect" />
href matches redirect_uri, wrong rel: <link rel="another_incorrect_redirect_uri" href="https://not-the-client.example.com/auth" />
EOT
),
$url
];
}
]);
$req = $this->getIARequest([
'client_id' => 'https://client.example.com/',
'redirect_uri' => 'https://not-the-client.example.com/auth'
]);
$res = $s->handleAuthorizationEndpointRequest($req);
$this->assertEquals((string) IndieAuthException::INVALID_REDIRECT_URI, (string) $res->getBody());
}
public function testReturnsAuthorizationFormIfClientIdSufficientlyMatchesRedirectUri() {
$s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array {
return ['me' => 'https://me.example.com'];
},
'httpGetWithEffectiveUrl' => function ($url): array {
return [
new Response(200, ['content-type' => 'text/html'], ''), // An empty response suffices for this test.
$url
];
}
]);
$req = $this->getIARequest([
'client_id' => 'https://client.example.com/',
'redirect_uri' => 'https://client.example.com/auth'
]);
$res = $s->handleAuthorizationEndpointRequest($req);
$this->assertEquals(200, $res->getStatusCode());
}
public function testReturnsAuthorizationFormIfClientIdExactlyMatchesParsedLinkHeaderRedirectUri() {
$s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array {
return ['me' => 'https://me.example.com'];
},
'httpGetWithEffectiveUrl' => function ($url): array {
return [
new Response(200, [
'content-type' => 'text/html',
'link' => '<https://link-header.example.com/auth>; rel="another_rel redirect_uri"'
],
''
),
$url
];
}
]);
$req = $this->getIARequest([
'client_id' => 'https://client.example.com/',
'redirect_uri' => 'https://link-header.example.com/auth'
]);
$res = $s->handleAuthorizationEndpointRequest($req);
$this->assertEquals(200, $res->getStatusCode());
}
public function testReturnsAuthorizationFormIfClientIdExactlyMatchesParsedLinkElementRedirectUri() {
$s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array {
return ['me' => 'https://me.example.com'];
},
'httpGetWithEffectiveUrl' => function ($url): array {
return [
new Response(200, ['content-type' => 'text/html'],
'<link rel="redirect_uri another_rel" href="https://link-element.example.com/auth" />'
),
$url
];
}
]);
$req = $this->getIARequest([
'client_id' => 'https://client.example.com/',
'redirect_uri' => 'https://link-element.example.com/auth'
]);
$res = $s->handleAuthorizationEndpointRequest($req);
$this->assertEquals(200, $res->getStatusCode());
}
public function testFindsFirstHAppExactlyMatchingClientId() {
$correctHAppName = 'Correct h-app!';
$correctHAppUrl = 'https://client.example.com/';
$correctHAppPhoto = 'https://client.example.com/logo.png';
$s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) {
return ['me' => 'https://me.example.com'];
},
'httpGetWithEffectiveUrl' => function ($url) use ($correctHAppPhoto, $correctHAppName, $correctHAppUrl) {
return [
new Response(200, ['content-type' => 'text/html'],
<<<EOT
<a class="h-app" href="https://not-the-client.example.com/">Wrong</a>
<a class="h-app" href="{$correctHAppUrl}"><img src="{$correctHAppPhoto}" alt="{$correctHAppName}" /></a>
EOT
),
$url
];
}
]);
$req = $this->getIARequest([
'client_id' => $correctHAppUrl,
'redirect_uri' => 'https://client.example.com/auth'
]);
$res = $s->handleAuthorizationEndpointRequest($req);
$this->assertEquals(200, $res->getStatusCode());
$parsedResponse = json_decode((string) $res->getBody(), true);
$flatHApp = $parsedResponse['clientHApp'];
$this->assertEquals($correctHAppUrl, $flatHApp['url']);
$this->assertEquals($correctHAppName, $flatHApp['name']);
$this->assertEquals($correctHAppPhoto, $flatHApp['photo']);
}
public function testNonIndieAuthRequestWithDefaultHandlerReturnsError() {
$res = $this->getDefaultServer()->handleAuthorizationEndpointRequest(new ServerRequest('GET', 'https://example.com'));
$this->assertEquals((string) IndieAuthException::INTERNAL_ERROR, (string) $res->getBody());
}
public function testResponseReturnedFromNonIndieAuthRequestHandler() {
$responseBody = 'A response to a non-indieauth request.';
$res = $this->getDefaultServer([
Server::HANDLE_NON_INDIEAUTH_REQUEST => function (ServerRequestInterface $request) use ($responseBody) {
return new Response(200, ['content-type' => 'text/plain'], $responseBody);
}
])->handleAuthorizationEndpointRequest(new ServerRequest('GET', 'https://example.com'));
$this->assertEquals($responseBody, (string) $res->getBody());
}
}
function scopeEquals($scope1, $scope2): bool {
$scope1 = is_string($scope1) ? explode(' ', $scope1) : $scope1;
$scope2 = is_string($scope2) ? explode(' ', $scope2) : $scope2;
sort($scope1);
sort($scope2);
return $scope1 == $scope2;
}