408 lines
20 KiB
PHP
408 lines
20 KiB
PHP
<?php declare(strict_types=1);
|
||
|
||
namespace Taproot\IndieAuth;
|
||
|
||
use Exception;
|
||
use IndieAuth\Client as IndieAuthClient;
|
||
use Mf2;
|
||
use BarnabyWalters\Mf2 as M;
|
||
use GuzzleHttp\Psr7\Header as HeaderParser;
|
||
use Nyholm\Psr7\Response;
|
||
use PHPUnit\Framework\Constraint\Callback;
|
||
use Psr\Http\Client\ClientInterface as HttpClientInterface;
|
||
use Psr\Http\Client\ClientExceptionInterface;
|
||
use Psr\Http\Client\NetworkExceptionInterface;
|
||
use Psr\Http\Client\RequestExceptionInterface;
|
||
use Psr\Http\Message\ResponseInterface;
|
||
use Psr\Http\Message\ServerRequestInterface;
|
||
use Psr\Http\Server\MiddlewareInterface;
|
||
use Psr\Log\LoggerInterface;
|
||
use Psr\Log\NullLogger;
|
||
use Taproot\IndieAuth\Callback\AuthorizationFormInterface;
|
||
use Taproot\IndieAuth\Callback\DefaultAuthorizationForm;
|
||
use Taproot\IndieAuth\Middleware\ResponseRequestHandler;
|
||
|
||
/**
|
||
* Development Reference
|
||
*
|
||
* Specification: https://indieauth.spec.indieweb.org/
|
||
* Error responses: https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2
|
||
* indieweb/indieauth-client: https://github.com/indieweb/indieauth-client-php
|
||
*/
|
||
|
||
class Server {
|
||
const HANDLE_NON_INDIEAUTH_REQUEST = 'handleNonIndieAuthRequestCallback';
|
||
const HANDLE_AUTHENTICATION_REQUEST = 'handleAuthenticationRequestCallback';
|
||
const HASH_QUERY_STRING_KEY = 'taproot_indieauth_server_hash';
|
||
const DEFAULT_CSRF_KEY = 'taproot_indieauth_server_csrf';
|
||
const APPROVE_ACTION_KEY = 'taproot_indieauth_action';
|
||
const APPROVE_ACTION_VALUE = 'approve';
|
||
|
||
protected Storage\TokenStorageInterface $authorizationCodeStorage;
|
||
|
||
protected Storage\TokenStorageInterface $accessTokenStorage;
|
||
|
||
protected AuthorizationFormInterface $authorizationForm;
|
||
|
||
protected MiddlewareInterface $csrfMiddleware;
|
||
|
||
protected LoggerInterface $logger;
|
||
|
||
protected $httpGetWithEffectiveUrl;
|
||
|
||
protected $handleAuthenticationRequestCallback;
|
||
|
||
protected $handleNonIndieAuthRequest;
|
||
|
||
protected string $exceptionTemplatePath;
|
||
|
||
protected string $secret;
|
||
|
||
protected int $tokenLength;
|
||
|
||
public function __construct(array $config) {
|
||
$config = array_merge([
|
||
'csrfMiddleware' => null,
|
||
'logger' => null,
|
||
self::HANDLE_NON_INDIEAUTH_REQUEST => function (ServerRequestInterface $request) { return null; }, // Default to no-op.
|
||
'authorizationCodeStorage' => null,
|
||
'accessTokenStorage' => null,
|
||
'httpGetWithEffectiveUrl' => null,
|
||
'authorizationForm' => new DefaultAuthorizationForm(),
|
||
'exceptionTemplatePath' => __DIR__ . '/../templates/default_exception_response.html.php',
|
||
'tokenLength' => 64
|
||
], $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'] ?? '';
|
||
if (!is_string($secret) || strlen($secret) < 64) {
|
||
throw new Exception("\$config['secret'] must be a string with a minimum length of 64 characters.");
|
||
}
|
||
$this->secret = $secret;
|
||
|
||
if (!is_null($config['logger']) && !$config['logger'] instanceof LoggerInterface) {
|
||
throw new Exception("\$config['logger'] must be an instance of \\Psr\\Log\\LoggerInterface or null.");
|
||
}
|
||
$this->logger = $config['logger'] ?? new NullLogger();
|
||
|
||
if (!(array_key_exists(self::HANDLE_AUTHENTICATION_REQUEST, $config) and is_callable($config[self::HANDLE_AUTHENTICATION_REQUEST]))) {
|
||
throw new Exception('$callbacks[\'' . self::HANDLE_AUTHENTICATION_REQUEST .'\'] must be present and callable.');
|
||
}
|
||
$this->handleAuthenticationRequestCallback = $config[self::HANDLE_AUTHENTICATION_REQUEST];
|
||
|
||
if (!is_callable($config[self::HANDLE_NON_INDIEAUTH_REQUEST])) {
|
||
throw new Exception("\$config['" . self::HANDLE_NON_INDIEAUTH_REQUEST . "'] must be callable");
|
||
}
|
||
$this->handleNonIndieAuthRequest = $config[self::HANDLE_NON_INDIEAUTH_REQUEST];
|
||
|
||
$authorizationCodeStorage = $config['authorizationCodeStorage'];
|
||
if (!$authorizationCodeStorage instanceof Storage\TokenStorageInterface) {
|
||
if (is_string($authorizationCodeStorage)) {
|
||
$authorizationCodeStorage = new Storage\FilesystemJsonStorage($authorizationCodeStorage, 600, true);
|
||
} else {
|
||
throw new Exception("\$config['authorizationCodeStorage'] must be either a string (path) or an instance of Taproot\IndieAuth\TokenStorageInterface.");
|
||
}
|
||
}
|
||
trySetLogger($authorizationCodeStorage, $this->logger);
|
||
$this->authorizationCodeStorage = $authorizationCodeStorage;
|
||
|
||
$accessTokenStorage = $config['accessTokenStorage'];
|
||
if (!$accessTokenStorage instanceof Storage\TokenStorageInterface) {
|
||
if (is_string($accessTokenStorage)) {
|
||
// Create a default access token storage with a TTL of 7 days.
|
||
$accessTokenStorage = new Storage\FilesystemJsonStorage($accessTokenStorage, 60 * 60 * 24 * 7, true);
|
||
} else {
|
||
throw new Exception('$accessTokenStorage parameter must be either a string (path) or an instance of Taproot\IndieAuth\TokenStorageInterface.');
|
||
}
|
||
}
|
||
trySetLogger($accessTokenStorage, $this->logger);
|
||
$this->accessTokenStorage = $accessTokenStorage;
|
||
|
||
$csrfMiddleware = $config['csrfMiddleware'];
|
||
if (!$csrfMiddleware instanceof MiddlewareInterface) {
|
||
// Default to the statless Double-Submit Cookie CSRF Middleware, with default settings.
|
||
$csrfMiddleware = new Middleware\DoubleSubmitCookieCsrfMiddleware(self::DEFAULT_CSRF_KEY);
|
||
}
|
||
trySetLogger($csrfMiddleware, $this->logger);
|
||
$this->csrfMiddleware = $csrfMiddleware;
|
||
|
||
$httpGetWithEffectiveUrl = $config['httpGetWithEffectiveUrl'];
|
||
if (!is_callable($httpGetWithEffectiveUrl)) {
|
||
if (class_exists('\GuzzleHttp\Client')) {
|
||
$httpGetWithEffectiveUrl = function (string $uri) {
|
||
// This code can’t be tested, ignore it for coverage purposes.
|
||
// @codeCoverageIgnoreStart
|
||
$resp = (new \GuzzleHttp\Client([
|
||
\GuzzleHttp\RequestOptions::ALLOW_REDIRECTS => [
|
||
'max' => 10,
|
||
'strict' => true,
|
||
'referer' => true,
|
||
'track_redirects' => true
|
||
]
|
||
]))->get($uri);
|
||
|
||
$rdh = $resp->getHeader('X-Guzzle-Redirect-History');
|
||
$effectiveUrl = empty($rdh) ? $uri : array_values($rdh)[count($rdh) - 1];
|
||
|
||
return [$resp, $effectiveUrl];
|
||
// @codeCoverageIgnoreEnd
|
||
};
|
||
} else {
|
||
throw new Exception('No valid $httpGetWithEffectiveUrl was provided, and guzzlehttp/guzzle was not installed. Either require guzzlehttp/guzzle, or provide a valid callable.');
|
||
}
|
||
}
|
||
trySetLogger($httpGetWithEffectiveUrl, $this->logger);
|
||
$this->httpGetWithEffectiveUrl = $httpGetWithEffectiveUrl;
|
||
|
||
if (!$config['authorizationForm'] instanceof AuthorizationFormInterface) {
|
||
throw new Exception("When provided, \$config['authorizationForm'] must implement Taproot\IndieAuth\Callback\AuthorizationForm.");
|
||
}
|
||
$this->authorizationForm = $config['authorizationForm'];
|
||
trySetLogger($this->authorizationForm, $this->logger);
|
||
}
|
||
|
||
/**
|
||
* Handle Authorization Endpoint Request
|
||
*
|
||
* @param ServerRequestInterface $request
|
||
* @return ResponseInterface
|
||
*/
|
||
public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface {
|
||
$this->logger->info('Handling an IndieAuth Authorization Endpoint request.');
|
||
|
||
// If it’s a profile information request:
|
||
if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) {
|
||
$this->logger->info('Handling a request to redeem an authorization code for profile information.');
|
||
// Verify that the authorization code is valid and has not yet been used.
|
||
$this->authorizationCodeStorage->get($request->getParsedBody()['code']);
|
||
|
||
// Verify that it was issued for the same client_id and redirect_uri
|
||
|
||
// Check that the supplied code_verifier hashes to the stored code_challenge
|
||
|
||
// If everything checked out, return {"me": "https://example.com"} response
|
||
// (a response containing any additional information must contain a valid scope value, and
|
||
// be handled by the token_endpoint).
|
||
// TODO: according to the spec, it is technically permitted for the authorization endpoint
|
||
// to additional provide profile information. Leave it up to the library consumer to decide
|
||
// whether to add it or not.
|
||
}
|
||
|
||
// Because the special case above isn’t allowed to be CSRF-protected, we have to do some rather silly
|
||
// closure gymnastics here to selectively-CSRF-protect requests which do need it.
|
||
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
|
||
// approval requests and custom auth form submission.
|
||
if (isIndieAuthAuthorizationRequest($request, ['get', 'post'])) {
|
||
$this->logger->info('Handling an authorization request', ['method' => $request->getMethod()]);
|
||
|
||
$queryParams = $request->getQueryParams();
|
||
// Return an error if we’re missing required parameters.
|
||
$requiredParameters = ['client_id', 'redirect_uri', 'state', 'code_challenge', 'code_challenge_method'];
|
||
$missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($queryParams) {
|
||
return !array_key_exists($p, $queryParams) || empty($queryParams[$p]);
|
||
});
|
||
if (!empty($missingRequiredParameters)) {
|
||
$this->logger->warning('The authorization request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]);
|
||
throw IndieAuthException::create(IndieAuthException::REQUEST_MISSING_PARAMETER, $request);
|
||
}
|
||
|
||
// Normalise the me parameter, if it exists.
|
||
if (array_key_exists('me', $queryParams)) {
|
||
$queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']);
|
||
}
|
||
|
||
// Build a URL containing the indieauth authorization request parameters, hashing them
|
||
// to protect them from being changed.
|
||
// Make a hash of the protected indieauth-specific parameters.
|
||
$hash = hashAuthorizationRequestParameters($request, $this->secret);
|
||
// Operate on a copy of $queryParams, otherwise requests will always have a valid hash!
|
||
$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.
|
||
$this->logger->info('Calling handle_authentication_request callback');
|
||
$authenticationResult = call_user_func($this->handleAuthenticationRequestCallback, $request, $authenticationRedirect);
|
||
|
||
// If the authentication handler returned a Response, return that as-is.
|
||
if ($authenticationResult instanceof ResponseInterface) {
|
||
return $authenticationResult;
|
||
} elseif (is_array($authenticationResult)) {
|
||
// Check the resulting array for errors.
|
||
if (!array_key_exists('me', $authenticationResult)) {
|
||
$this->logger->error('The handle_authentication_request callback returned an array with no me key.', ['array' => $authenticationResult]);
|
||
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, it’s possible for hashAuthorizationRequestParameters() to return null, and if for whatever
|
||
// reason it does, the library should handle that case as elegantly as possible.
|
||
// @codeCoverageIgnoreStart
|
||
$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);
|
||
// @codeCoverageIgnoreEnd
|
||
}
|
||
|
||
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 isn’t 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.
|
||
try {
|
||
/** @var ResponseInterface $clientIdResponse */
|
||
list($clientIdResponse, $clientIdEffectiveUrl) = call_user_func($this->httpGetWithEffectiveUrl, $queryParams['client_id']);
|
||
$clientIdMf2 = Mf2\parse((string) $clientIdResponse->getBody(), $clientIdEffectiveUrl);
|
||
} catch (ClientExceptionInterface | RequestExceptionInterface | NetworkExceptionInterface $e) {
|
||
$this->logger->error("Caught an HTTP exception while trying to fetch the client_id. Returning an error response.", [
|
||
'client_id' => $queryParams['client_id'],
|
||
'exception' => $e->__toString()
|
||
]);
|
||
|
||
throw IndieAuthException::create(IndieAuthException::HTTP_EXCEPTION_FETCHING_CLIENT_ID, $request, $e);
|
||
} catch (Exception $e) {
|
||
$this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [
|
||
'exception' => $e->__toString()
|
||
]);
|
||
|
||
throw IndieAuthException::create(IndieAuthException::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID, $request, $e);
|
||
}
|
||
|
||
// Search for an h-app with u-url matching the client_id.
|
||
$clientHApps = M\findMicroformatsByProperty(M\findMicroformatsByType($clientIdMf2, 'h-app'), 'url', $queryParams['client_id']);
|
||
$clientHApp = empty($clientHApps) ? null : $clientHApps[0];
|
||
|
||
// Search for all link@rel=redirect_uri at the client_id.
|
||
$clientIdRedirectUris = [];
|
||
if (array_key_exists('redirect_uri', $clientIdMf2['rels'])) {
|
||
$clientIdRedirectUris = array_merge($clientIdRedirectUris, $clientIdMf2['rels']['redirect_uri']);
|
||
}
|
||
|
||
foreach (HeaderParser::parse($clientIdResponse->getHeader('Link')) as $link) {
|
||
if (array_key_exists('rel', $link) && mb_strpos(" {$link['rel']} ", " redirect_uri ") !== false) {
|
||
// Strip off the < > which surround the link URL for some reason.
|
||
$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.
|
||
$clientIdMatchesRedirectUri = urlComponentsMatch($queryParams['client_id'], $queryParams['redirect_uri'], [PHP_URL_SCHEME, PHP_URL_HOST, PHP_URL_PORT]);
|
||
$redirectUriValid = $clientIdMatchesRedirectUri || in_array($queryParams['redirect_uri'], $clientIdRedirectUris);
|
||
|
||
if (!$redirectUriValid) {
|
||
$this->logger->warning("The provided redirect_uri did not match either the client_id, nor the discovered redirect URIs.", [
|
||
'provided_redirect_uri' => $queryParams['redirect_uri'],
|
||
'provided_client_id' => $queryParams['client_id'],
|
||
'discovered_redirect_uris' => $clientIdRedirectUris
|
||
]);
|
||
|
||
throw IndieAuthException::create(IndieAuthException::INVALID_REDIRECT_URI, $request);
|
||
}
|
||
|
||
// Present the authorization UI.
|
||
return $this->authorizationForm->showForm($request, $authenticationResult, $authenticationRedirect, $clientHApp);
|
||
}
|
||
}
|
||
|
||
// If the request isn’t an IndieAuth Authorization or Code-redeeming request, it’s either an invalid
|
||
// request or something to do with a custom auth handler (e.g. sending a one-time code in an email.)
|
||
$nonIndieAuthRequestResult = call_user_func($this->handleNonIndieAuthRequest, $request);
|
||
if ($nonIndieAuthRequestResult instanceof ResponseInterface) {
|
||
return $nonIndieAuthRequestResult;
|
||
} else {
|
||
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));
|
||
}
|
||
}));
|
||
}
|
||
|
||
public function handleTokenEndpointRequest(ServerRequestInterface $request): ResponseInterface {
|
||
// This is a request to redeem an authorization_code for an access_token.
|
||
|
||
// Verify that the authorization code is valid and has not yet been used.
|
||
|
||
// Verify that it was issued for the same client_id and redirect_uri
|
||
|
||
// Check that the supplied code_verifier hashes to the stored code_challenge
|
||
|
||
// If the auth code was issued with no scope, return an error.
|
||
|
||
// 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
|
||
]));
|
||
}
|
||
}
|