Refactored Exception Handling, mostly tested authorization request handler

* Internal error conditions now raise IndieAuthException
* Bubbled unknown exceptions converted to generic IndieAuthException
* Exceptions passed to overridable handler, turned into response
* Wrote many more tests, fixed a variety of problems
This commit is contained in:
Barnaby Walters 2021-06-09 00:06:35 +02:00
parent 61bc3d7418
commit 6d5e93b07c
10 changed files with 700 additions and 168 deletions

View File

@ -33,6 +33,13 @@ class DefaultAuthorizationForm implements AuthorizationFormInterface, LoggerAwar
$scopes[$s] = null; // Ideally there would be a description of the scope here, we dont have one though. $scopes[$s] = null; // Ideally there would be a description of the scope here, we dont have one though.
} }
if (is_null($clientHApp)) {
$clientHApp = [
'type' => ['h-app'],
'properties' => []
];
}
$hApp = [ $hApp = [
'name' => M\getProp($clientHApp, 'name'), 'name' => M\getProp($clientHApp, 'name'),
'url' => M\getProp($clientHApp, 'url'), 'url' => M\getProp($clientHApp, 'url'),
@ -62,7 +69,6 @@ class DefaultAuthorizationForm implements AuthorizationFormInterface, LoggerAwar
// You may wish to additionally make any other necessary changes to the the code based on // You may wish to additionally make any other necessary changes to the the code based on
// the form submission, e.g. if the user set a custom token lifetime, or wanted extra data // the form submission, e.g. if the user set a custom token lifetime, or wanted extra data
// stored on the token to affect how it behaves. // stored on the token to affect how it behaves.
return $code; return $code;
} }

View File

@ -0,0 +1,60 @@
<?php declare(strict_types=1);
namespace Taproot\IndieAuth;
use Exception;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;
class IndieAuthException extends Exception {
const INTERNAL_ERROR = 0;
const REQUEST_MISSING_PARAMETER = 1;
const AUTHENTICATION_CALLBACK_MISSING_ME_PARAM = 2;
const AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH = 3;
const AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH = 4;
const HTTP_EXCEPTION_FETCHING_CLIENT_ID = 5;
const INTERNAL_EXCEPTION_FETCHING_CLIENT_ID = 6;
const INVALID_REDIRECT_URI = 7;
const EXC_INFO = [
self::INTERNAL_ERROR => ['statusCode' => 500, 'name' => 'Internal Server Error', 'explanation' => 'An internal server error occurred.'],
self::REQUEST_MISSING_PARAMETER => ['statusCode' => 400, 'name' => 'Request Missing Parameter', 'explanation' => 'The request was missing one or more required query string parameters.'],
self::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM => ['statusCode' => 500, 'name' => 'Internal Server Error', 'explanation' => 'The user data returned from handleAuthenticationRequestCallback was missing a “me” parameter.'],
self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH => ['statusCode' => 400, 'name' => 'Request Missing Hash', 'explanation' => 'An authentication form submission request was missing the hash parameter.'],
self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH => ['statusCode' => 400, 'name' => 'Request Hash Invalid', 'explanation' => 'The hash protecting the query string parameters from tampering was invalid. Your form submission may have been altered by malicious client-side code.'],
// TODO: should this one be a 500 because its an internal server error, or a 400 because the client_id was likely invalid? Is anyone ever going to notice, or care?
self::HTTP_EXCEPTION_FETCHING_CLIENT_ID => ['statusCode' => 500, 'name' => 'Error Fetching Client App URL', 'explanation' => 'Fetching the client app (client_id) failed.'],
self::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID => ['statusCode' => 500, 'name' => 'Internal Error fetching client app URI', 'explanation' => 'Fetching the client app (client_id) failed due to an internal error.'],
self::INVALID_REDIRECT_URI => ['statusCode' => 400, 'name' => 'Invalid Client App Redirect URI', 'explanation' => 'The client app redirect URI (redirect_uri) did not sufficiently match client_id, or exactly match any redirect URIs parsed from fetching the client_id.']
];
protected ServerRequestInterface $request;
public static function create(int $code, ServerRequestInterface $request, ?Throwable $previous=null): self {
// Only accept known codes. Default to 0 (generic internal error) on an unrecognised code.
if (!in_array($code, array_keys(self::EXC_INFO))) {
$code = 0;
}
$message = self::EXC_INFO[$code]['name'];
$e = new self($message, $code, $previous);
$e->request = $request;
return $e;
}
public function getStatusCode() {
return self::EXC_INFO[$this->code]['statusCode'] ?? 500;
}
public function getExplanation() {
return self::EXC_INFO[$this->code]['explanation'] ?? 'An unknown error occured.';
}
public function trustQueryParams() {
return $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH
|| $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH;
}
public function getRequest() {
return $this->request;
}
}

View File

@ -35,29 +35,31 @@ class Server {
const HANDLE_AUTHENTICATION_REQUEST = 'handleAuthenticationRequestCallback'; const HANDLE_AUTHENTICATION_REQUEST = 'handleAuthenticationRequestCallback';
const HASH_QUERY_STRING_KEY = 'taproot_indieauth_server_hash'; const HASH_QUERY_STRING_KEY = 'taproot_indieauth_server_hash';
const DEFAULT_CSRF_KEY = 'taproot_indieauth_server_csrf'; const DEFAULT_CSRF_KEY = 'taproot_indieauth_server_csrf';
const APPROVE_ACTION_KEY = 'taproot_indieauth_action';
const APPROVE_ACTION_VALUE = 'approve';
public $callbacks; protected Storage\TokenStorageInterface $authorizationCodeStorage;
public Storage\TokenStorageInterface $authorizationCodeStorage; protected Storage\TokenStorageInterface $accessTokenStorage;
public Storage\TokenStorageInterface $accessTokenStorage; protected AuthorizationFormInterface $authorizationForm;
public AuthorizationFormInterface $authorizationForm; protected MiddlewareInterface $csrfMiddleware;
public MiddlewareInterface $csrfMiddleware; protected LoggerInterface $logger;
public LoggerInterface $logger; protected $httpGetWithEffectiveUrl;
public HttpClientInterface $httpClient; protected $handleAuthenticationRequestCallback;
public $httpGetWithEffectiveUrl; protected $handleNonIndieAuthRequest;
public $handleAuthenticationRequestCallback; protected string $exceptionTemplatePath;
public $handleNonIndieAuthRequest;
protected string $secret; protected string $secret;
protected int $tokenLength;
public function __construct(array $config) { public function __construct(array $config) {
$config = array_merge([ $config = array_merge([
'csrfMiddleware' => null, 'csrfMiddleware' => null,
@ -67,8 +69,20 @@ class Server {
'accessTokenStorage' => null, 'accessTokenStorage' => null,
'httpGetWithEffectiveUrl' => null, 'httpGetWithEffectiveUrl' => null,
'authorizationForm' => new DefaultAuthorizationForm(), 'authorizationForm' => new DefaultAuthorizationForm(),
'exceptionTemplatePath' => __DIR__ . '/templates/default_exception_response.html.php',
'tokenLength' => 64
], $config); ], $config);
if (!is_int($config['tokenLength'])) {
throw new Exception("\$config['tokenLength'] must be an int!");
}
$this->tokenLength = $config['tokenLength'];
if (!is_string($config['exceptionTemplatePath'])) {
throw new Exception("\$config['secret'] must be a string (path).");
}
$this->exceptionTemplatePath = $config['exceptionTemplatePath'];
$secret = $config['secret'] ?? ''; $secret = $config['secret'] ?? '';
if (!is_string($secret) || strlen($secret) < 64) { if (!is_string($secret) || strlen($secret) < 64) {
throw new Exception("\$config['secret'] must be a string with a minimum length of 64 characters."); throw new Exception("\$config['secret'] must be a string with a minimum length of 64 characters.");
@ -153,6 +167,12 @@ class Server {
trySetLogger($this->authorizationForm, $this->logger); trySetLogger($this->authorizationForm, $this->logger);
} }
/**
* Handle Authorization Endpoint Request
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface { public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface {
$this->logger->info('Handling an IndieAuth Authorization Endpoint request.'); $this->logger->info('Handling an IndieAuth Authorization Endpoint request.');
@ -175,8 +195,12 @@ class Server {
} }
// Because the special case above isnt allowed to be CSRF-protected, we have to do some rather silly // Because the special case above isnt allowed to be CSRF-protected, we have to do some rather silly
// gymnastics here to selectively-CSRF-protect requests which do need it. // closure gymnastics here to selectively-CSRF-protect requests which do need it.
return $this->csrfMiddleware->process($request, new Middleware\ClosureRequestHandler(function (ServerRequestInterface $request) { return $this->csrfMiddleware->process($request, new Middleware\ClosureRequestHandler(function (ServerRequestInterface $request) {
// Wrap the entire user-facing handler in a try/catch block which catches any exception, converts it
// to IndieAuthException if necessary, then passes it to $this->handleException() to be turned into a
// response.
try {
// If this is an authorization or approval request (allowing POST requests as well to accommodate // If this is an authorization or approval request (allowing POST requests as well to accommodate
// approval requests and custom auth form submission. // approval requests and custom auth form submission.
if (isIndieAuthAuthorizationRequest($request, ['get', 'post'])) { if (isIndieAuthAuthorizationRequest($request, ['get', 'post'])) {
@ -190,8 +214,7 @@ class Server {
}); });
if (!empty($missingRequiredParameters)) { if (!empty($missingRequiredParameters)) {
$this->logger->warning('The authorization request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]); $this->logger->warning('The authorization request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]);
// TODO: return a better response, or at least allow the library consumer to configure a better response. throw IndieAuthException::create(IndieAuthException::REQUEST_MISSING_PARAMETER, $request);
return new Response(400, ['content-type' => 'text/plain'], 'The IndieAuth request was missing the following parameters: ' . join("\n", $missingRequiredParameters));
} }
// Normalise the me parameter, if it exists. // Normalise the me parameter, if it exists.
@ -203,8 +226,10 @@ class Server {
// to protect them from being changed. // to protect them from being changed.
// Make a hash of the protected indieauth-specific parameters. // Make a hash of the protected indieauth-specific parameters.
$hash = hashAuthorizationRequestParameters($request, $this->secret); $hash = hashAuthorizationRequestParameters($request, $this->secret);
$queryParams[self::HASH_QUERY_STRING_KEY] = $hash; // Operate on a copy of $queryParams, otherwise requests will always have a valid hash!
$authenticationRedirect = $request->getUri()->withQuery(buildQueryString($queryParams))->__toString(); $redirectQueryParams = $queryParams;
$redirectQueryParams[self::HASH_QUERY_STRING_KEY] = $hash;
$authenticationRedirect = $request->getUri()->withQuery(buildQueryString($redirectQueryParams))->__toString();
// User-facing requests always start by calling the authentication request callback. // User-facing requests always start by calling the authentication request callback.
$this->logger->info('Calling handle_authentication_request callback'); $this->logger->info('Calling handle_authentication_request callback');
@ -217,9 +242,68 @@ class Server {
// Check the resulting array for errors. // Check the resulting array for errors.
if (!array_key_exists('me', $authenticationResult)) { if (!array_key_exists('me', $authenticationResult)) {
$this->logger->error('The handle_authentication_request callback returned an array with no me key.', ['array' => $authenticationResult]); $this->logger->error('The handle_authentication_request callback returned an array with no me key.', ['array' => $authenticationResult]);
return new Response(500, ['content-type' => 'text/plain'], 'An internal error occurred.'); throw IndieAuthException::create(IndieAuthException::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM, $request);
} }
// If this is a POST request sent from the authorization (i.e. scope-choosing) form:
if (isAuthorizationApprovalRequest($request)) {
// Authorization approval requests MUST include a hash protecting the sensitive IndieAuth
// authorization request parameters from being changed, e.g. by a malicious script which
// found its way onto the authorization form.
$expectedHash = hashAuthorizationRequestParameters($request, $this->secret);
if (is_null($expectedHash)) {
// In theory this code should never be reached, as we already checked the request for valid parameters.
// However, its possible for hashAuthorizationRequestParameters() to return null, and if for whatever
// reason it does, the library should handle that case as elegantly as possible.
$this->logger->warning("Calculating the expected hash for an authorization approval request failed. This SHOULD NOT happen; if you encounter this error please contact the maintainers of taproot/indieauth.");
throw IndieAuthException::create(IndieAuthException::REQUEST_MISSING_PARAMETER, $request);
}
if (!array_key_exists(self::HASH_QUERY_STRING_KEY, $queryParams)) {
$this->logger->warning("An authorization approval request did not have a " . self::HASH_QUERY_STRING_KEY . " parameter.");
throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH, $request);
}
if (!hash_equals($expectedHash, $queryParams[self::HASH_QUERY_STRING_KEY])) {
$this->logger->warning("The hash provided in the URL was invalid!", [
'expected' => $expectedHash,
'actual' => $queryParams[self::HASH_QUERY_STRING_KEY]
]);
throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH, $request);
}
// Assemble the data for the authorization code, store it somewhere persistent.
$code = array_merge($authenticationResult, [
'client_id' => $queryParams['client_id'],
'redirect_uri' => $queryParams['redirect_uri'],
'state' => $queryParams['state'],
'code_challenge' => $queryParams['code_challenge'],
'code_challenge_method' => $queryParams['code_challenge_method'],
'requested_scope' => $queryParams['scope'] ?? '',
'code' => generateRandomString($this->tokenLength)
]);
// Pass it to the auth code customisation callback.
$code = $this->authorizationForm->transformAuthorizationCode($request, $code);
// Store the authorization code.
$success = $this->authorizationCodeStorage->put($code['code'], $code);
if (!$success) {
// If saving the authorization code failed silently, there isnt much we can do about it,
// but should at least log and return an error.
$this->logger->error("Saving the authorization code failed and returned false without raising an exception.");
throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR, $request);
}
// Return a redirect to the client app.
return new Response(302, ['Location' => appendQueryParams($queryParams['redirect_uri'], [
'code' => $code['code'],
'state' => $code['state']
])]);
}
// Otherwise, the user is authenticated and needs to authorize the client app + choose scopes.
// Fetch the client_id URL to find information about the client to present to the user. // Fetch the client_id URL to find information about the client to present to the user.
try { try {
/** @var ResponseInterface $clientIdResponse */ /** @var ResponseInterface $clientIdResponse */
@ -231,37 +315,34 @@ class Server {
'exception' => $e->__toString() 'exception' => $e->__toString()
]); ]);
return new Response(500, ['content-type' => 'text/plain'], 'An internal error occurred.'); throw IndieAuthException::create(IndieAuthException::HTTP_EXCEPTION_FETCHING_CLIENT_ID, $request, $e);
} catch (Exception $e) { } catch (Exception $e) {
$this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [ $this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [
'exception' => $e->__toString() 'exception' => $e->__toString()
]); ]);
return new Response(500, ['content-type' => 'text/plain'], 'An internal error occurred.'); throw IndieAuthException::create(IndieAuthException::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID, $request, $e);
} }
// Search for an h-app with u-url matching the client_id. // Search for an h-app with u-url matching the client_id.
$clientHApps = M\findMicroformatsByProperty(M\findMicroformatsByType($clientIdMf2, 'h-app'), 'url', $queryParams['client-id']); $clientHApps = M\findMicroformatsByProperty(M\findMicroformatsByType($clientIdMf2, 'h-app'), 'url', $queryParams['client_id']);
$clientHApp = empty($clientHApps) ? null : $clientHApps[0]; $clientHApp = empty($clientHApps) ? null : $clientHApps[0];
// Search for all link@rel=redirect_uri at the client_id. // Search for all link@rel=redirect_uri at the client_id.
$clientIdRedirectUris = []; $clientIdRedirectUris = [];
if (array_key_exists('redirect_uri', $clientIdMf2['rels'])) { if (array_key_exists('redirect_uri', $clientIdMf2['rels'])) {
$clientIdRedirectUris = array_merge($clientIdRedirectUris, $clientIdMf2['rels']); $clientIdRedirectUris = array_merge($clientIdRedirectUris, $clientIdMf2['rels']['redirect_uri']);
} }
foreach (HeaderParser::parse($clientIdResponse->getHeader('Link')) as $link) { foreach (HeaderParser::parse($clientIdResponse->getHeader('Link')) as $link) {
if (array_key_exists('rel', $link) and str_contains(" {$link['rel']} ", " redirect_uri ")) { if (array_key_exists('rel', $link) && mb_strpos(" {$link['rel']} ", " redirect_uri ") !== false) {
// Strip off the < > which surround the link URL for some reason. // Strip off the < > which surround the link URL for some reason.
$clientIdRedirectUris[] = substr($link[0], 1, strlen($link[0]) - 2); $clientIdRedirectUris[] = substr($link[0], 1, strlen($link[0]) - 2);
} }
} }
// If the authority of the redirect_uri does not match the client_id, or exactly match one of their redirect URLs, return an error. // If the authority of the redirect_uri does not match the client_id, or exactly match one of their redirect URLs, return an error.
$cidComponents = M\parseUrl($queryParams['client_id']); $clientIdMatchesRedirectUri = urlComponentsMatch($queryParams['client_id'], $queryParams['redirect_uri'], [PHP_URL_SCHEME, PHP_URL_HOST, PHP_URL_PORT]);
$ruriComponents = M\parseUrl($queryParams['redirect_uri']);
$clientIdMatchesRedirectUri = $cidComponents['scheme'] == $ruriComponents['scheme']
&& $cidComponents['host'] == $ruriComponents['host']
&& $cidComponents['port'] == $ruriComponents['port'];
$redirectUriValid = $clientIdMatchesRedirectUri || in_array($queryParams['redirect_uri'], $clientIdRedirectUris); $redirectUriValid = $clientIdMatchesRedirectUri || in_array($queryParams['redirect_uri'], $clientIdRedirectUris);
if (!$redirectUriValid) { if (!$redirectUriValid) {
@ -271,53 +352,9 @@ class Server {
'discovered_redirect_uris' => $clientIdRedirectUris 'discovered_redirect_uris' => $clientIdRedirectUris
]); ]);
return new Response(500, ['content-type' => 'text/plain'], 'An internal error occurred.'); throw IndieAuthException::create(IndieAuthException::INVALID_REDIRECT_URI, $request);
} }
// If this is a POST request sent from the authorization (i.e. scope-choosing) form:
if (isAuthorizationApprovalRequest($request)) {
// Authorization approval requests MUST include a hash protecting the sensitive IndieAuth
// authorization request parameters from being changed, e.g. by a malicious script which
// found its way onto the authorization form.
$expectedHash = hashAuthorizationRequestParameters($request, $this->secret);
if (is_null($expectedHash)) {
$this->logger->warning("An authorization approval request did not have a " . self::HASH_QUERY_STRING_KEY . " parameter.");
return new Response(400, ['content-type' => 'text/plain'], 'The ' . self::HASH_QUERY_STRING_KEY . ' parameter was missing!');
}
if (!hash_equals($expectedHash, $queryParams[self::HASH_QUERY_STRING_KEY])) {
$this->logger->warning("The hash provided in the URL was invalid!", [
'expected' => $expectedHash,
'actual' => $queryParams[self::HASH_QUERY_STRING_KEY]
]);
return new Response(400, ['content-type' => 'text/plain'], 'Invalid hash!');
}
// Assemble the data for the authorization code, store it somewhere persistent.
$code = array_merge($authenticationResult, [
'client_id' => $queryParams['client_id'],
'redirect_uri' => $queryParams['redirect_uri'],
'state' => $queryParams['state'],
'code_challenge' => $queryParams['code_challenge'],
'code_challenge_method' => $queryParams['code_challenge_method'],
'requested_scope' => $queryParams['scope'] ?? '',
'code' => generateRandomString(256)
]);
// Pass it to the auth code customisation callback, if any.
$code = $this->authorizationForm->transformAuthorizationCode($request, $code);
// Store the authorization code.
$this->authorizationCodeStorage->put($code['code'], $code);
// Return a redirect to the client app.
return new Response(302, ['Location' => appendQueryParams($queryParams['redirect_uri'], [
'code' => $code['code'],
'state' => $code['state']
])]);
}
// Otherwise, the user is authenticated and needs to authorize the client app + choose scopes.
// Present the authorization UI. // Present the authorization UI.
return $this->authorizationForm->showForm($request, $authenticationResult, $authenticationRedirect, $clientHApp); return $this->authorizationForm->showForm($request, $authenticationResult, $authenticationRedirect, $clientHApp);
} }
@ -329,7 +366,15 @@ class Server {
if ($nonIndieAuthRequestResult instanceof ResponseInterface) { if ($nonIndieAuthRequestResult instanceof ResponseInterface) {
return $nonIndieAuthRequestResult; return $nonIndieAuthRequestResult;
} else { } else {
return new Response(400, ['content-type' => 'text/plain'], 'Invalid request!'); throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR, $request);
}
} catch (IndieAuthException $e) {
// All IndieAuthExceptions will already have been logged.
return $this->handleException($e);
} catch (Exception $e) {
// Unknown exceptions will not have been logged; do so now.
$this->logger->error("Caught unknown exception: {$e}");
return $this->handleException(IndieAuthException::create(0, $request, $e));
} }
})); }));
} }
@ -347,4 +392,11 @@ class Server {
// If everything checks out, generate an access token and return it. // If everything checks out, generate an access token and return it.
} }
protected function handleException(IndieAuthException $exception): ResponseInterface {
return new Response($exception->getStatusCode(), ['content-type' => 'text/html'], renderTemplate($this->exceptionTemplatePath, [
'request' => $exception->getRequest(),
'exception' => $exception
]));
}
} }

View File

@ -2,10 +2,14 @@
namespace Taproot\IndieAuth; namespace Taproot\IndieAuth;
use Exception;
use IndieAuth\Client;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use function BarnabyWalters\Mf2\parseUrl;
// From https://github.com/indieweb/indieauth-client-php/blob/main/src/IndieAuth/Client.php, thanks aaronpk. // From https://github.com/indieweb/indieauth-client-php/blob/main/src/IndieAuth/Client.php, thanks aaronpk.
function generateRandomString($numBytes) { function generateRandomString($numBytes) {
if (function_exists('random_bytes')) { if (function_exists('random_bytes')) {
@ -51,7 +55,7 @@ function isIndieAuthAuthorizationRequest(ServerRequestInterface $request, $permi
function isAuthorizationApprovalRequest(ServerRequestInterface $request) { function isAuthorizationApprovalRequest(ServerRequestInterface $request) {
return strtolower($request->getMethod()) == 'post' return strtolower($request->getMethod()) == 'post'
&& array_key_exists('taproot_indieauth_action', $request->getParsedBody()) && array_key_exists('taproot_indieauth_action', $request->getParsedBody())
&& $request->getParsedBody()['taproot_indieauth_action'] == 'approve'; && $request->getParsedBody()[Server::APPROVE_ACTION_KEY] == Server::APPROVE_ACTION_VALUE;
} }
function buildQueryString(array $parameters) { function buildQueryString(array $parameters) {
@ -62,6 +66,23 @@ function buildQueryString(array $parameters) {
return join('&', $qs); return join('&', $qs);
} }
function urlComponentsMatch($url1, $url2, ?array $components=null): bool {
$validComponents = [PHP_URL_HOST, PHP_URL_PASS, PHP_URL_PATH, PHP_URL_PORT, PHP_URL_USER, PHP_URL_QUERY, PHP_URL_SCHEME, PHP_URL_FRAGMENT];
$components = $components ?? $validComponents;
foreach ($components as $cmp) {
if (!in_array($cmp, $validComponents)) {
throw new Exception("Invalid parse_url() component passed: $cmp");
}
if (parse_url($url1, $cmp) !== parse_url($url2, $cmp)) {
return false;
}
}
return true;
}
/** /**
* Append Query Parameters * Append Query Parameters
* *

View File

@ -1,4 +1,7 @@
<?php <?php
use Taproot\IndieAuth\Server;
/** @var string $formAction The URL to POST to to authorize the app, or to set as the redirect URL for a logout action if the user wants to continue as a different user. */ /** @var string $formAction The URL to POST to to authorize the app, or to set as the redirect URL for a logout action if the user wants to continue as a different user. */
/** @var Psr\Http\Message\ServerRequestInterface $request */ /** @var Psr\Http\Message\ServerRequestInterface $request */
/** @var array|null $clientHApp */ /** @var array|null $clientHApp */
@ -107,7 +110,7 @@
<a class="cancel-link" href="<?= htmlentities($clientId) ?>">Cancel (back to <?= $clientHApp['name'] ?? 'app' ?>)</a> <a class="cancel-link" href="<?= htmlentities($clientId) ?>">Cancel (back to <?= $clientHApp['name'] ?? 'app' ?>)</a>
<!-- Your form MUST be submitted with taproot_indieauth_action=approve for the approval submission to work. --> <!-- Your form MUST be submitted with taproot_indieauth_action=approve for the approval submission to work. -->
<button type="submit" name="taproot_indieauth_action" value="approve">Authorize</button> <button type="submit" name="<?= Server::APPROVE_ACTION_KEY ?>" value="<?= Server::APPROVE_ACTION_VALUE ?>">Authorize</button>
</p> </p>
</div> </div>
</form> </form>

View File

@ -0,0 +1,32 @@
<?php
/** @var Taproot\IndieAuth\IndieAuthExcepton $exception */
/** @var Psr\Http\Message\ServerRequestInterface $request */
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>IndieAuth Error!</title>
<style>
</style>
</head>
<body>
<h1>Error: <?= htmlentities($exception->getMessage()) ?></h1>
<p><?= htmlentities($exception->getExplanation()) ?></p>
<!-- If $exception->trustQueryParams() returns false, then the query parameters have been tampered with
and we shouldnt offer the user a redirect back to the client_id! -->
<?php if ($exception->trustQueryParams()): ?>
<p><a href="<?= htmlentities($request->getQueryParams()['client_id']) ?>">Return to app (<?= htmlentities($request->getQueryParams()['client_id']) ?> ?>)</a>
<?php endif ?>
<!-- Youll probably want to offer the user a suitable link to leave the flow. -->
<footer>
<small>Powered by <a href="https://taprootproject.com">taproot/indieauth</a></small>
</footer>
</body>
</html>

View File

@ -2,17 +2,30 @@
namespace Taproot\IndieAuth\Test; namespace Taproot\IndieAuth\Test;
use Exception;
use Nyholm\Psr7\Response; use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\ServerRequest;
use Nyholm\Psr7\Request;
use PDO;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Taproot\IndieAuth\Callback\DefaultAuthorizationForm;
use Taproot\IndieAuth\Callback\SingleUserPasswordAuthenticationCallback; use Taproot\IndieAuth\Callback\SingleUserPasswordAuthenticationCallback;
use Taproot\IndieAuth\IndieAuthException;
use Taproot\IndieAuth\Middleware\NoOpMiddleware;
use Taproot\IndieAuth\Server; use Taproot\IndieAuth\Server;
use Taproot\IndieAuth\Storage\FilesystemJsonStorage; use Taproot\IndieAuth\Storage\FilesystemJsonStorage;
use function Taproot\IndieAuth\hashAuthorizationRequestParameters;
use function Taproot\IndieAuth\urlComponentsMatch;
const SERVER_SECRET = '1111111111111111111111111111111111111111111111111111111111111111'; const SERVER_SECRET = '1111111111111111111111111111111111111111111111111111111111111111';
const AUTH_CODE_STORAGE_PATH = __DIR__ . '/tmp/authorization_codes'; const AUTH_CODE_STORAGE_PATH = __DIR__ . '/tmp/authorization_codes';
const ACCESS_TOKEN_STORAGE_PATH = __DIR__ . '/tmp/authorization_codes'; const ACCESS_TOKEN_STORAGE_PATH = __DIR__ . '/tmp/authorization_codes';
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'; const TMP_DIR = __DIR__ . '/tmp';
class ServerTest extends TestCase { class ServerTest extends TestCase {
@ -21,11 +34,15 @@ class ServerTest extends TestCase {
'secret' => SERVER_SECRET, 'secret' => SERVER_SECRET,
'authorizationCodeStorage' => AUTH_CODE_STORAGE_PATH, 'authorizationCodeStorage' => AUTH_CODE_STORAGE_PATH,
'accessTokenStorage' => ACCESS_TOKEN_STORAGE_PATH, 'accessTokenStorage' => ACCESS_TOKEN_STORAGE_PATH,
Server::HANDLE_AUTHENTICATION_REQUEST => new SingleUserPasswordAuthenticationCallback(['me' => 'https://example.com/'], password_hash('password', PASSWORD_DEFAULT)) // 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)); ], $config));
} }
protected function getIARequest(array $params=[]) { protected function getIARequest(array $params=[]): ServerRequestInterface {
return (new ServerRequest('GET', 'https://example.com/'))->withQueryParams(array_merge([ return (new ServerRequest('GET', 'https://example.com/'))->withQueryParams(array_merge([
'response_type' => 'code', 'response_type' => 'code',
'client_id' => 'https://app.example.com/', 'client_id' => 'https://app.example.com/',
@ -36,6 +53,36 @@ class ServerTest extends TestCase {
], $params)); ], $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 { protected function setUp(): void {
// Clean up tmp folder. // Clean up tmp folder.
new FilesystemJsonStorage(AUTH_CODE_STORAGE_PATH, -1, true); new FilesystemJsonStorage(AUTH_CODE_STORAGE_PATH, -1, true);
@ -55,9 +102,11 @@ class ServerTest extends TestCase {
public function testAuthorizationRequestMissingParametersReturnsError() { public function testAuthorizationRequestMissingParametersReturnsError() {
$s = $this->getDefaultServer(); $s = $this->getDefaultServer();
$req = (new ServerRequest('GET', 'https://example.com/')); $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); $res = $s->handleAuthorizationEndpointRequest($req);
$this->assertEquals(400, $res->getStatusCode()); $this->assertEquals((string) IndieAuthException::REQUEST_MISSING_PARAMETER, (string) $res->getBody());
} }
public function testUnauthenticatedRequestReturnsAuthenticationResponse() { public function testUnauthenticatedRequestReturnsAuthenticationResponse() {
@ -83,27 +132,311 @@ class ServerTest extends TestCase {
$res = $s->handleAuthorizationEndpointRequest($this->getIARequest()); $res = $s->handleAuthorizationEndpointRequest($this->getIARequest());
$this->assertEquals(500, $res->getStatusCode()); $this->assertEquals((string) IndieAuthException::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM, (string) $res->getBody());
} }
public function testReturnServerErrorIfFetchingClientIdThrowsException() { public function testReturnErrorIfFetchingClientIdThrowsException() {
$exceptionClasses = ['GuzzleHttp\Exception\ConnectException', 'GuzzleHttp\Exception\RequestException']; $exceptionClasses = [
foreach ($exceptionClasses as $eClass) { 'GuzzleHttp\Exception\ConnectException' => (string) IndieAuthException::HTTP_EXCEPTION_FETCHING_CLIENT_ID,
$req = $this->getIARequest(); '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([ $s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) { Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) {
return ['me' => 'https://example.com/']; return ['me' => 'https://example.com/'];
}, },
'httpGetWithEffectiveUrl' => function ($url) use ($eClass, $req) { 'httpGetWithEffectiveUrl' => function ($url) use ($eClass) {
throw new $eClass($eClass, $req); 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); $res = $s->handleAuthorizationEndpointRequest($req);
$this->assertEquals(500, $res->getStatusCode()); $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(AUTH_CODE_STORAGE_PATH);
$storedCode = $storage->get($redirectUriQueryParams['code']);
$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;
} }

View File

@ -0,0 +1,21 @@
<?php
/** @var string $formAction The URL to POST to to authorize the app, or to set as the redirect URL for a logout action if the user wants to continue as a different user. */
/** @var Psr\Http\Message\ServerRequestInterface $request */
/** @var array|null $clientHApp */
/** @var array $user */
/** @var array $scopes */
/** @var string $clientId */
/** @var string $clientRedirectUri */
/** @var string $csrfFormElement A pre-rendered CSRF form element which must be output inside the authorization form. */
echo json_encode([
'formAction' => $formAction,
'clientHApp' => $clientHApp,
'user' => $user,
'scopes' => $scopes,
'clientId' => $clientId,
'clientRedirectUri' => $clientRedirectUri,
'csrfFormElement' => $csrfFormElement
]);
?>

View File

@ -0,0 +1,4 @@
<?php
/** @var Taproot\IndieAuth\IndieAuthExcepton $exception */
/** @var Psr\Http\Message\ServerRequestInterface $request */
?><?= $exception->getCode() ?>