diff --git a/composer.json b/composer.json index 4623b0c..6464524 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,10 @@ "indieauth/client": "^1.1", "psr/http-client": "^1.0", "psr/http-server-middleware": "^1.0", - "dflydev/fig-cookies": "^3.0" + "dflydev/fig-cookies": "^3.0", + "mf2/mf2": "^0.4.6", + "barnabywalters/mf-cleaner": "^0.1.4", + "guzzlehttp/psr7": "^1.8" }, "require-dev": { "guzzlehttp/guzzle": "^7.3" diff --git a/composer.lock b/composer.lock index 01e17a6..31b00fa 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,52 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e7b261990b1458b0da10c6ff5fa2a6d2", + "content-hash": "a3c5834a1f1214cdf5ec23a831b9c2cf", "packages": [ + { + "name": "barnabywalters/mf-cleaner", + "version": "v0.1.4", + "source": { + "type": "git", + "url": "https://github.com/barnabywalters/php-mf-cleaner.git", + "reference": "ef6a16628db6e8aee2b4f8bb8093d18c24b74cd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barnabywalters/php-mf-cleaner/zipball/ef6a16628db6e8aee2b4f8bb8093d18c24b74cd4", + "reference": "ef6a16628db6e8aee2b4f8bb8093d18c24b74cd4", + "shasum": "" + }, + "require-dev": { + "php": ">=5.3", + "phpunit/phpunit": "*" + }, + "suggest": { + "mf2/mf2": "To parse microformats2 structures from (X)HTML" + }, + "type": "library", + "autoload": { + "files": [ + "src/BarnabyWalters/Mf2/Functions.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barnaby Walters", + "email": "barnaby@waterpigs.co.uk" + } + ], + "description": "Cleans up microformats2 array structures", + "support": { + "issues": "https://github.com/barnabywalters/php-mf-cleaner/issues", + "source": "https://github.com/barnabywalters/php-mf-cleaner/tree/v0.1.4" + }, + "time": "2014-10-06T23:11:15+00:00" + }, { "name": "dflydev/fig-cookies", "version": "v3.0.0", @@ -68,6 +112,81 @@ }, "time": "2021-01-22T02:53:56+00:00" }, + { + "name": "guzzlehttp/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "dc960a912984efb74d0a90222870c72c87f10c91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/dc960a912984efb74d0a90222870c72c87f10c91", + "reference": "dc960a912984efb74d0a90222870c72c87f10c91", + "shasum": "" + }, + "require": { + "php": ">=5.4.0", + "psr/http-message": "~1.0", + "ralouphie/getallheaders": "^2.0.5 || ^3.0.0" + }, + "provide": { + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "ext-zlib": "*", + "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.10" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.7-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Schultze", + "homepage": "https://github.com/Tobion" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/1.8.2" + }, + "time": "2021-04-26T09:17:50+00:00" + }, { "name": "indieauth/client", "version": "1.1.5", @@ -775,6 +894,50 @@ "source": "https://github.com/php-fig/log/tree/1.1.4" }, "time": "2021-05-03T11:20:27+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" } ], "packages-dev": [ @@ -935,125 +1098,6 @@ "source": "https://github.com/guzzle/promises/tree/1.4.1" }, "time": "2021-03-07T09:25:29+00:00" - }, - { - "name": "guzzlehttp/psr7", - "version": "1.8.2", - "source": { - "type": "git", - "url": "https://github.com/guzzle/psr7.git", - "reference": "dc960a912984efb74d0a90222870c72c87f10c91" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/dc960a912984efb74d0a90222870c72c87f10c91", - "reference": "dc960a912984efb74d0a90222870c72c87f10c91", - "shasum": "" - }, - "require": { - "php": ">=5.4.0", - "psr/http-message": "~1.0", - "ralouphie/getallheaders": "^2.0.5 || ^3.0.0" - }, - "provide": { - "psr/http-message-implementation": "1.0" - }, - "require-dev": { - "ext-zlib": "*", - "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.10" - }, - "suggest": { - "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.7-dev" - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Psr7\\": "src/" - }, - "files": [ - "src/functions_include.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Tobias Schultze", - "homepage": "https://github.com/Tobion" - } - ], - "description": "PSR-7 message implementation that also provides common utility methods", - "keywords": [ - "http", - "message", - "psr-7", - "request", - "response", - "stream", - "uri", - "url" - ], - "support": { - "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/1.8.2" - }, - "time": "2021-04-26T09:17:50+00:00" - }, - { - "name": "ralouphie/getallheaders", - "version": "3.0.3", - "source": { - "type": "git", - "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "120b605dfeb996808c31b6477290a714d356e822" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", - "reference": "120b605dfeb996808c31b6477290a714d356e822", - "shasum": "" - }, - "require": { - "php": ">=5.6" - }, - "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^5 || ^6.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/getallheaders.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ralph Khattar", - "email": "ralph.khattar@gmail.com" - } - ], - "description": "A polyfill for getallheaders.", - "support": { - "issues": "https://github.com/ralouphie/getallheaders/issues", - "source": "https://github.com/ralouphie/getallheaders/tree/develop" - }, - "time": "2019-03-08T08:55:37+00:00" } ], "aliases": [], diff --git a/src/Taproot/IndieAuth/DoubleSubmitCookieCsrfMiddleware.php b/src/Taproot/IndieAuth/DoubleSubmitCookieCsrfMiddleware.php index c78b988..f280cb8 100644 --- a/src/Taproot/IndieAuth/DoubleSubmitCookieCsrfMiddleware.php +++ b/src/Taproot/IndieAuth/DoubleSubmitCookieCsrfMiddleware.php @@ -8,6 +8,9 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Dflydev\FigCookies; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; // From https://github.com/indieweb/indieauth-client-php/blob/main/src/IndieAuth/Client.php, thanks aaronpk. function generateRandomString($numBytes) { @@ -24,7 +27,7 @@ function generateRandomString($numBytes) { return bin2hex($bytes); } -class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface { +class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface, LoggerAwareInterface { const READ_METHODS = ['HEAD', 'GET', 'OPTIONS']; const TTL = 60 * 20; const ATTRIBUTE = 'csrf'; @@ -39,7 +42,9 @@ class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface { public int $tokenLength; - public function __construct(string $attribute=self::ATTRIBUTE, int $ttl=self::TTL, $errorResponse=self::DEFAULT_ERROR_RESPONSE_STRING, $tokenLength=self::CSRF_TOKEN_LENGTH) { + public LoggerInterface $logger; + + public function __construct(string $attribute=self::ATTRIBUTE, int $ttl=self::TTL, $errorResponse=self::DEFAULT_ERROR_RESPONSE_STRING, $tokenLength=self::CSRF_TOKEN_LENGTH, $logger=null) { $this->attribute = $attribute; $this->ttl = $ttl; $this->tokenLength = $tokenLength; @@ -54,6 +59,15 @@ class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface { $errorResponse = function (ServerRequestInterface $request) use ($errorResponse) { return $errorResponse; }; } $this->errorResponse = $errorResponse; + + if (!$logger instanceof LoggerInterface) { + $logger = new NullLogger(); + } + $this->logger = $logger; + } + + public function setLogger(LoggerInterface $logger) { + $this->logger = $logger; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { diff --git a/src/Taproot/IndieAuth/FilesystemJsonStorage.php b/src/Taproot/IndieAuth/FilesystemJsonStorage.php index ddc50b2..8112f95 100644 --- a/src/Taproot/IndieAuth/FilesystemJsonStorage.php +++ b/src/Taproot/IndieAuth/FilesystemJsonStorage.php @@ -23,9 +23,9 @@ class FilesystemJsonStorage implements TokenStorageInterface { $deleted = 0; // A TTL of 0 means the token should live until deleted, and negative TTLs are invalid. - if ($ttl >= 0) { + if ($ttl > 0) { foreach (new DirectoryIterator($this->path) as $fileInfo) { - if ($fileInfo->isFile() && time() - max($fileInfo->getMTime(), $fileInfo->getCTime()) > $ttl) { + if ($fileInfo->isFile() && $fileInfo->getExtension() == 'json' && time() - max($fileInfo->getMTime(), $fileInfo->getCTime()) > $ttl) { unlink($fileInfo->getPathname()); $deleted++; } diff --git a/src/Taproot/IndieAuth/NoOpMiddleware.php b/src/Taproot/IndieAuth/NoOpMiddleware.php index 41fa786..f39cd88 100644 --- a/src/Taproot/IndieAuth/NoOpMiddleware.php +++ b/src/Taproot/IndieAuth/NoOpMiddleware.php @@ -7,8 +7,14 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * No-Op Middleware + * + * A PSR-15 Middleware which does nothing, simply passing `$request` onto `$handler` and returning + * the response. + */ class NoOpMiddleware implements MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { return $handler->handle($request); } -} \ No newline at end of file +} diff --git a/src/Taproot/IndieAuth/Server.php b/src/Taproot/IndieAuth/Server.php index ffe80c4..008c657 100644 --- a/src/Taproot/IndieAuth/Server.php +++ b/src/Taproot/IndieAuth/Server.php @@ -3,15 +3,27 @@ namespace Taproot\IndieAuth; use Exception; +use GuzzleHttp\Exception\ServerException; +use IndieAuth\Client as IndieAuthClient; +use Mf2; +use BarnabyWalters\Mf2 as M; +use GuzzleHttp\Psr7\Header as HeaderParser; use Nyholm\Psr7\Response; -use GuzzleHttp\Psr7\ServerRequest; +use Nyholm\Psr7\Request; +use Nyholm\Psr7\ServerRequest; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\ClientInterface as HttpClientInterface; +use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Http\Client\RequestExceptionInterface; use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use IndieAuth\Client as IndieAuthClient; -use Psr\Http\Server\MiddlewareInterface; + +use function PHPSTORM_META\type; /** * Development Reference @@ -51,6 +63,26 @@ function buildQueryString(array $parameters) { return join('&', $qs); } +/** + * Append Query Parameters + * + * Converts `$queryParams` into a query string, then checks `$uri` for an + * existing query string. Then appends the newly generated query string + * with either ? or & as appropriate. + */ +function appendQueryParams(string $uri, array $queryParams) { + $queryString = buildQueryString($queryParams); + $separator = parse_url($uri, \PHP_URL_QUERY) ? '&' : '?'; + return "{$uri}{$separator}{$queryString}"; +} + +function trySetLogger($target, LoggerInterface $logger) { + if ($target instanceof LoggerAwareInterface) { + $target->setLogger($logger); + } + return $target; +} + class Server { const CUSTOMIZE_AUTHORIZATION_CODE = 'customise_authorization_code'; const SHOW_AUTHORIZATION_PAGE = 'show_authorization_page'; @@ -68,20 +100,39 @@ class Server { public LoggerInterface $logger; + public HttpClientInterface $httpClient; + + public callable $httpGetWithEffectiveUrl; + public string $csrfKey; - public function __construct(array $callbacks, $authorizationCodeStorage, $accessTokenStorage, $csrfMiddleware=null, string $csrfKey=self::DEFAULT_CSRF_KEY, LoggerInterface $logger=null) { - $callbacks = array_merge([ - self::CUSTOMIZE_AUTHORIZATION_CODE => function (array $code) { return $code; }, // Default to no-op. - self::SHOW_AUTHORIZATION_PAGE => function (ServerRequestInterface $request, array $authenticationResult, string $authenticationRedirect) { }, // TODO: Put the default implementation here. - self::HANDLE_NON_INDIEAUTH_REQUEST => function (ServerRequestInterface $request) { return null; }, // Default to no-op. - ], $callbacks); + public function __construct(array $config) { + $config = array_merge_recursive([ + 'csrfMiddleware' => null, + 'csrfKey' => self::DEFAULT_CSRF_KEY, + 'logger' => null, + 'callbacks' => [ + self::CUSTOMIZE_AUTHORIZATION_CODE => function (array $code) { return $code; }, // Default to no-op. + self::SHOW_AUTHORIZATION_PAGE => function (ServerRequestInterface $request, array $authenticationResult, string $authenticationRedirect) { }, // TODO: Put the default implementation here. + self::HANDLE_NON_INDIEAUTH_REQUEST => function (ServerRequestInterface $request) { return null; }, // Default to no-op. + ], + 'authorizationCodeStorage' => null, + 'accessTokenStorage' => null, + 'httpGetWithEffectiveUrl' => null + ], $config); + if (!$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(); + + $callbacks = $config['callbacks']; if (!(array_key_exists(self::HANDLE_AUTHENTICATION_REQUEST, $callbacks) and is_callable($callbacks[self::HANDLE_AUTHENTICATION_REQUEST]))) { throw new Exception('$callbacks[\'' . self::HANDLE_AUTHENTICATION_REQUEST .'\'] must be present and callable.'); } $this->callbacks = $callbacks; - + + $authorizationCodeStorage = $config['authorizationCodeStorage']; if (!$authorizationCodeStorage instanceof TokenStorageInterface) { if (is_string($authorizationCodeStorage)) { $authorizationCodeStorage = new FilesystemJsonStorage($authorizationCodeStorage, 600, true); @@ -89,8 +140,10 @@ class Server { throw new Exception('$authorizationCodeStorage parameter 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 TokenStorageInterface) { if (is_string($accessTokenStorage)) { // Create a default access token storage with a TTL of 7 days. @@ -99,22 +152,51 @@ class Server { 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; - $this->csrfKey = $csrfKey; + $this->csrfKey = $config['csrfKey']; + $csrfMiddleware = $config['csrfMiddleware']; if (!$csrfMiddleware instanceof MiddlewareInterface) { // Default to the statless Double-Submit Cookie CSRF Middleware, with default settings. $csrfMiddleware = new DoubleSubmitCookieCsrfMiddleware($this->csrfKey); } + trySetLogger($csrfMiddleware, $this->logger); $this->csrfMiddleware = $csrfMiddleware; - $this->logger = $logger ?? new NullLogger(); + $httpGetWithEffectiveUrl = $config['httpGetWithEffectiveUrl']; + if (!is_callable($httpGetWithEffectiveUrl)) { + if (class_exists('\GuzzleHttp\Client')) { + $httpGetWithEffectiveUrl = function (string $uri) { + $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]; + }; + } 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; } 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']); @@ -136,10 +218,30 @@ class Server { // 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]); + // TODO: return a better response, or at least allow the library consumer to configure a better response. + 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. + if (array_key_exists('me', $queryParams)) { + $queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']); + } + // Build a URL for the authentication flow to redirect to, if it needs to. // TODO: perhaps filter queryParams to only include the indieauth-relevant params? - $authenticationRedirect = $request->getUri() . '?' . buildQueryString($request->getQueryParams()); + $authenticationRedirect = $request->getUri() . '?' . buildQueryString($queryParams); + $this->logger->info('Calling handle_authentication_request callback'); $authenticationResult = call_user_func($this->callbacks[self::HANDLE_AUTHENTICATION_REQUEST], $request, $authenticationRedirect); // If the authentication handler returned a Response, return that as-is. @@ -147,15 +249,77 @@ class Server { 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]); + return new Response(500, ['content-type' => 'text/plain'], 'An internal error occurred.'); + } - // The user is logged in. + // 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($clientIdResponse->getBody()->getContents(), $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() + ]); + + return new Response(500, ['content-type' => 'text/plain'], 'An internal error occurred.'); + } catch (Exception $e) { + $this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [ + 'exception' => $e->__toString() + ]); + + return new Response(500, ['content-type' => 'text/plain'], 'An internal error occurred.'); + } + + // 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']); + } + foreach (HeaderParser::parse($clientIdResponse->getHeader('Link')) as $link) { + if (array_key_exists('rel', $link) and str_contains(" {$link['rel']} ", " redirect_uri ")) { + // 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. + $cidComponents = M\parseUrl($queryParams['client_id']); + $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); + + 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 + ]); + + return new Response(500, ['content-type' => 'text/plain'], 'An internal error occurred.'); + } // If this is a POST request sent from the authorization (i.e. scope-choosing) form: if (isAuthorizationApprovalRequest($request)) { // Assemble the data for the authorization code, store it somewhere persistent. - $code = [ - - ]; + $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 = call_user_func($this->callbacks[self::CUSTOMIZE_AUTHORIZATION_CODE], $code, $request); @@ -164,16 +328,16 @@ class Server { $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. - // Fetch the client_id URL to find information about the client to present to the user. - - // If the authority of the redirect_uri does not match the client_id or one of their redirect URLs, return an error. - // Present the authorization UI. - return call_user_func($this->callbacks[self::SHOW_AUTHORIZATION_PAGE], $request, $authenticationResult, $authenticationRedirect); + return call_user_func($this->callbacks[self::SHOW_AUTHORIZATION_PAGE], $request, $authenticationResult, $authenticationRedirect, $clientHApp); } }