Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
60.00% covered (warning)
60.00%
3 / 5
CRAP
94.90% covered (success)
94.90%
242 / 255
Server
0.00% covered (danger)
0.00%
0 / 1
60.00% covered (warning)
60.00%
3 / 5
83.91
94.90% covered (success)
94.90%
242 / 255
 __construct
100.00% covered (success)
100.00%
1 / 1
17
100.00% covered (success)
100.00%
45 / 45
 getTokenStorage
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 handleAuthorizationEndpointRequest
0.00% covered (danger)
0.00%
0 / 1
51.93
92.90% covered (success)
92.90%
144 / 155
 handleTokenEndpointRequest
0.00% covered (danger)
0.00%
0 / 1
11
95.35% covered (success)
95.35%
41 / 43
 handleException
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
11 / 11
1<?php declare(strict_types=1);
2
3namespace Taproot\IndieAuth;
4
5use BadMethodCallException;
6use BarnabyWalters\Mf2 as M;
7use Exception;
8use GuzzleHttp\Psr7\Header as HeaderParser;
9use IndieAuth\Client as IndieAuthClient;
10use Mf2;
11use Nyholm\Psr7\Response;
12use PDO;
13use Psr\Http\Client\ClientExceptionInterface;
14use Psr\Http\Client\NetworkExceptionInterface;
15use Psr\Http\Client\RequestExceptionInterface;
16use Psr\Http\Message\ResponseInterface;
17use Psr\Http\Message\ServerRequestInterface;
18use Psr\Http\Server\MiddlewareInterface;
19use Psr\Log\LoggerInterface;
20use Psr\Log\NullLogger;
21use Taproot\IndieAuth\Callback\AuthorizationFormInterface;
22use Taproot\IndieAuth\Callback\DefaultAuthorizationForm;
23use Taproot\IndieAuth\Storage\TokenStorageInterface;
24
25/**
26 * IndieAuth Server
27 * 
28 * A PSR-7-compatible implementation of the request-handling logic for IndieAuth authorization endpoints
29 * and token endpoints.
30 * 
31 * Typical minimal usage looks something like this:
32 *     
33 *     // Somewhere in your app set-up code:
34 *     $server = new Taproot\IndieAuth\Server([
35 *       // A secret key, >= 64 characters long.
36 *       'secret' => YOUR_APP_INDIEAUTH_SECRET,
37 * 
38 *       // A path to store token data, or an object implementing TokenStorageInterface.
39 *       'tokenStorage' => '/../data/auth_tokens/',
40 * 
41 *       // An authentication callback function, which either returns data about the current user,
42 *       // or redirects to/implements an authentication flow.
43 *       'authenticationHandler' => function (ServerRequestInterface $request, string $authenticationRedirect, ?string $normalizedMeUrl) {
44 *         // If the request is authenticated, return an array with a `me` key containing the
45 *         // canonical URL of the currently logged-in user.
46 *         if ($userUrl = getLoggedInUserUrl($request)) {
47 *           return ['me' => $userUrl];
48 *         }
49 *         
50 *         // Otherwise, redirect the user to a login page, ensuring that they will be redirected
51 *         // back to the IndieAuth flow with query parameters intact once logged in.
52 *         return new Response('302', ['Location' => 'https://example.com/login?next=' . urlencode($authenticationRedirect)]);
53 *       }
54 *     ]);
55 *     
56 *     // In your authorization endpoint route:
57 *     return $server->handleAuthorizationEndpointRequest($request);
58 *     
59 *     // In your token endpoint route:
60 *     return $server->handleTokenEndpointRequest($request);
61 *     
62 *     // In another route (e.g. a micropub route), to authenticate the request:
63 *     // (assuming $bearerToken is a token parsed from an “Authorization: Bearer XXXXXX” header
64 *     // or access_token property from a request body)
65 *     if ($accessToken = $server->getTokenStorage()->getAccessToken($bearerToken)) {
66 *       // Request is authenticated as $accessToken['me'], and is allowed to
67 *       // act according to the scopes listed in $accessToken['scope'].
68 *       $scopes = explode(' ', $accessToken['scope']);
69 *     }
70 * 
71 * Refer to the `__construct` documentation for further configuration options, and to the
72 * documentation for both handling methods for further documentation about them.
73 * 
74 * @link https://indieauth.spec.indieweb.org/
75 * @link https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2
76 * @link https://github.com/indieweb/indieauth-client-php
77 * @link https://github.com/Zegnat/php-mindee/blob/development/index.php
78 */
79class Server {
80    const HANDLE_NON_INDIEAUTH_REQUEST = 'handleNonIndieAuthRequestCallback';
81    const HANDLE_AUTHENTICATION_REQUEST = 'authenticationHandler';
82
83    /**
84     * The query string parameter key used for storing the hash used for validating authorization request parameters.
85     */
86    const HASH_QUERY_STRING_KEY = 'taproot_indieauth_server_hash';
87
88    /**
89     * The key used to store the CSRF token everywhere it’s used: Request parameters, Request body, and Cookies.
90     */
91    const DEFAULT_CSRF_KEY = 'taproot_indieauth_server_csrf';
92
93    /**
94     * The form data key used for identifying a request as an authorization (consent screen) form submissions.
95     */
96    const APPROVE_ACTION_KEY = 'taproot_indieauth_action';
97
98    /**
99     * The form data value used for identifying a request as an authorization (consent screen) form submissions.
100     */
101    const APPROVE_ACTION_VALUE = 'approve';
102
103    protected Storage\TokenStorageInterface $tokenStorage;
104
105    protected AuthorizationFormInterface $authorizationForm;
106
107    protected MiddlewareInterface $csrfMiddleware;
108
109    protected LoggerInterface $logger;
110
111    protected $httpGetWithEffectiveUrl;
112
113    protected $handleAuthenticationRequestCallback;
114
115    protected $handleNonIndieAuthRequest;
116
117    protected string $exceptionTemplatePath;
118
119    protected string $secret;
120
121    /**
122     * Constructor
123     * 
124     * Server instances are configured by passing a config array to the constructor.
125     * 
126     * The following keys are required:
127     * 
128     * * `authenticationHandler`: a callable with the signature
129     *   `function (ServerRequestInterface $request, string $authenticationRedirect, ?string $normalizedMeUrl): array|ResponseInterface`.
130     *   This function is called on IndieAuth authorization requests, after validating the query parameters.
131     *   
132     *   It should check to see if $request is authenticated, then:
133     *     * If it is authenticated, return an array which MUST have a `me` key, mapping to the 
134     *       canonical URL of the currently logged-in user. It may additionally have a `profile` key. These
135     *       keys will be stored in the authorization code and sent to the client, if successful.
136     *     * If it is not authenticated, either present or redirect to an authentication flow. This flow MUST
137     *       redirect the logged-in used back to `$authenticationRedirect`.
138     *   
139     *   If the request has a valid `me` parameter, the canonicalized version of it is passed as
140     *   `$normalizedMeUrl`. Otherwise, this parameter is null. This parameter can optionally be used 
141     *   as a suggestion for which user to log in as in a multi-user authentication flow, but should NOT
142     *   be considered valid data.
143     *   
144     *   If redirecting to an existing authentication flow, this callable can usually be implemented as a
145     *   closure. The callable may also implement its own authentication logic. For an example, see 
146     *   `Callback\SingleUserPasswordAuthenticationCallback`.
147     * * `secret`: A cryptographically random string with a minimum length of 64 characters. Used
148     *   to hash and subsequently verify request query parameters which get passed around.
149     * * `tokenStorage`: Either an object implementing `Storage\TokenStorageInterface`, or a string path,
150     *   which will be passed to `Storage\FilesystemJsonStorage`. This object handles persisting authorization
151     *   codes and access tokens, as well as implementation-specific parts of the exchange process which are 
152     *   out of the scope of the Server class (e.g. lifetimes and expiry). Refer to the `Storage\TokenStorageInterface`
153     *   documentation for more details.
154     * 
155     * The following keys may be required depending on which packages you have installed:
156     * 
157     * * `httpGetWithEffectiveUrl`: must be a callable with the following signature:
158     *   `function (string $url): array [ResponseInterface $response, string $effectiveUrl]`, where 
159     *   `$effectiveUrl` is the final URL after following any redirects (unfortunately, neither the PSR-7
160     *   Response nor the PSR-18 Client interfaces offer a standard way of getting this very important
161     *   data, hence the unusual return signature).  If `guzzlehttp/guzzle` is installed, this parameter
162     *   will be created automatically. Otherwise, the user must provide their own callable.
163     * 
164     * The following keys are optional:
165     * 
166     * * `authorizationForm`: an instance of `AuthorizationFormInterface`. Defaults to `DefaultAuthorizationForm`.
167     *   Refer to that implementation if you wish to replace the consent screen/scope choosing/authorization form.
168     * * `csrfMiddleware`: an instance of `MiddlewareInterface`, which will be used to CSRF-protect the
169     *   user-facing authorization flow. By default an instance of `DoubleSubmitCookieCsrfMiddleware`.
170     *   Refer to that implementation if you want to replace it with your own middleware — you will 
171     *   likely have to either make sure your middleware sets the same request attribute, or alter your
172     *   templates accordingly.
173     * * `exceptionTemplatePath`: string, path to a template which will be used for displaying user-facing
174     *   errors. Defaults to `../templates/default_exception_response.html.php`, refer to that if you wish
175     *   to write your own template.
176     * * `handleNonIndieAuthRequestCallback`: A callback with the following signature:
177     *   `function (ServerRequestInterface $request): ?ResponseInterface` which will be called if the
178     *   authorization endpoint gets a request which is not identified as an IndieAuth request or authorization
179     *   form submission request. You could use this to handle various requests e.g. client-side requests
180     *   made by your authentication or authorization pages, if it’s not convenient to put them elsewhere.
181     *   Returning `null` will result in a standard `invalid_request` error being returned.
182     * * `logger`: An instance of `LoggerInterface`. Will be used for internal logging, and will also be set
183     *   as the logger for any objects passed in config which implement `LoggerAwareInterface`.
184     * 
185     * @param array $config An array of configuration variables
186     * @return self
187     */
188    public function __construct(array $config) {
189        $config = array_merge([
190            'csrfMiddleware' => new Middleware\DoubleSubmitCookieCsrfMiddleware(self::DEFAULT_CSRF_KEY),
191            'logger' => null,
192            self::HANDLE_NON_INDIEAUTH_REQUEST => function (ServerRequestInterface $request) { return null; }, // Default to no-op.
193            'tokenStorage' => null,
194            'httpGetWithEffectiveUrl' => null,
195            'authorizationForm' => new DefaultAuthorizationForm(),
196            'exceptionTemplatePath' => __DIR__ . '/../templates/default_exception_response.html.php',
197        ], $config);
198
199        if (!is_string($config['exceptionTemplatePath'])) {
200            throw new BadMethodCallException("\$config['exceptionTemplatePath'] must be a string (path).");
201        }
202        $this->exceptionTemplatePath = $config['exceptionTemplatePath'];
203
204        $secret = $config['secret'] ?? '';
205        if (!is_string($secret) || strlen($secret) < 64) {
206            throw new BadMethodCallException("\$config['secret'] must be a string with a minimum length of 64 characters.");
207        }
208        $this->secret = $secret;
209
210        if (!is_null($config['logger']) && !$config['logger'] instanceof LoggerInterface) {
211            throw new BadMethodCallException("\$config['logger'] must be an instance of \\Psr\\Log\\LoggerInterface or null.");
212        }
213        $this->logger = $config['logger'] ?? new NullLogger();
214
215        if (!(array_key_exists(self::HANDLE_AUTHENTICATION_REQUEST, $config) and is_callable($config[self::HANDLE_AUTHENTICATION_REQUEST]))) {
216            throw new BadMethodCallException('$callbacks[\'' . self::HANDLE_AUTHENTICATION_REQUEST .'\'] must be present and callable.');
217        }
218        $this->handleAuthenticationRequestCallback = $config[self::HANDLE_AUTHENTICATION_REQUEST];
219        
220        if (!is_callable($config[self::HANDLE_NON_INDIEAUTH_REQUEST])) {
221            throw new BadMethodCallException("\$config['" . self::HANDLE_NON_INDIEAUTH_REQUEST . "'] must be callable");
222        }
223        $this->handleNonIndieAuthRequest = $config[self::HANDLE_NON_INDIEAUTH_REQUEST];
224
225        $tokenStorage = $config['tokenStorage'];
226        if (!$tokenStorage instanceof Storage\TokenStorageInterface) {
227            if (is_string($tokenStorage)) {
228                // Create a default access token storage with a TTL of 7 days.
229                $tokenStorage = new Storage\FilesystemJsonStorage($tokenStorage, $this->secret);
230            } else {
231                throw new BadMethodCallException("\$config['tokenStorage'] parameter must be either a string (path) or an instance of Taproot\IndieAuth\TokenStorageInterface.");
232            }
233        }
234        trySetLogger($tokenStorage, $this->logger);
235        $this->tokenStorage = $tokenStorage;
236
237        $csrfMiddleware = $config['csrfMiddleware'];
238        if (!$csrfMiddleware instanceof MiddlewareInterface) {
239            throw new BadMethodCallException("\$config['csrfMiddleware'] must be null or implement MiddlewareInterface.");
240        }
241        trySetLogger($csrfMiddleware, $this->logger);
242        $this->csrfMiddleware = $csrfMiddleware;
243
244        $httpGetWithEffectiveUrl = $config['httpGetWithEffectiveUrl'];
245        if (is_null($httpGetWithEffectiveUrl)) {
246            if (class_exists('\GuzzleHttp\Client')) {
247                $httpGetWithEffectiveUrl = function (string $uri) {
248                    // This code can’t be tested, ignore it for coverage purposes.
249                    // @codeCoverageIgnoreStart
250                    $resp = (new \GuzzleHttp\Client([
251                        \GuzzleHttp\RequestOptions::ALLOW_REDIRECTS => [
252                            'max' => 10,
253                            'strict' => true,
254                            'referer' => true,
255                            'track_redirects' => true
256                        ]
257                    ]))->get($uri);
258                    
259                    $rdh = $resp->getHeader('X-Guzzle-Redirect-History');
260                    $effectiveUrl = empty($rdh) ? $uri : array_values($rdh)[count($rdh) - 1];
261
262                    return [$resp, $effectiveUrl];
263                };
264            } else {
265                throw new BadMethodCallException("\$config['httpGetWithEffectiveUrl'] was not provided, and guzzlehttp/guzzle was not installed. Either require guzzlehttp/guzzle, or provide a valid callable.");
266                // @codeCoverageIgnoreEnd
267            }
268        } else {
269            if (!is_callable($httpGetWithEffectiveUrl)) {
270                throw new BadMethodCallException("\$config['httpGetWithEffectiveUrl'] must be callable.");
271            }
272        }
273        trySetLogger($httpGetWithEffectiveUrl, $this->logger);
274        $this->httpGetWithEffectiveUrl = $httpGetWithEffectiveUrl;
275
276        if (!$config['authorizationForm'] instanceof AuthorizationFormInterface) {
277            throw new BadMethodCallException("When provided, \$config['authorizationForm'] must implement Taproot\IndieAuth\Callback\AuthorizationForm.");
278        }
279        $this->authorizationForm = $config['authorizationForm'];
280        trySetLogger($this->authorizationForm, $this->logger);
281    }
282
283    public function getTokenStorage(): TokenStorageInterface {
284        return $this->tokenStorage;
285    }
286
287    /**
288     * Handle Authorization Endpoint Request
289     * 
290     * This method handles all requests to your authorization endpoint, passing execution off to
291     * other callbacks when necessary. The logical flow can be summarised as follows:
292     * 
293     * * If this request an **auth code exchange for profile information**, validate the request
294     *   and return a response or error response.
295     * * Otherwise, proceed, wrapping all execution in CSRF-protection middleware.
296     * * Validate the request’s indieauth authorization code request parameters, returning an 
297     *   error response if any are missing or invalid.
298     * * Call the authentication callback
299     *     * If the callback returned an instance of ResponseInterface, the user is not currently
300     *       logged in. Return the Response, which will presumably start an authentication flow.
301     *     * Otherwise, the callback returned information about the currently logged-in user. Continue.
302     * * If this request is an authorization form submission, validate the data, store and authorization
303     *   code and return a redirect response to the client redirect_uri with code data. On an error, return
304     *   an appropriate error response.
305     * * Otherwise, fetch the client_id, parse app data if present, validate the `redirect_uri` and present
306     *   the authorization form/consent screen to the user.
307     * * If none of the above apply, try calling the non-indieauth request handler. If it returns a Response,
308     *   return that, otherwise return an error response.
309     * 
310     * This route should NOT be wrapped in additional CSRF-protection, due to the need to handle API 
311     * POST requests from the client. Make sure you call it from a route which is excluded from any
312     * CSRF-protection you might be using. To customise the CSRF protection used internally, refer to the
313     * `__construct` config array documentation for the `csrfMiddleware` key.
314     * 
315     * Most user-facing errors are thrown as instances of `IndieAuthException`, which are passed off to
316     * `handleException` to be turned into an instance of `ResponseInterface`. If you want to customise
317     * error behaviour, one way to do so is to subclass `Server` and override that method.
318     * 
319     * @param ServerRequestInterface $request
320     * @return ResponseInterface
321     */
322    public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface {
323        $this->logger->info('Handling an IndieAuth Authorization Endpoint request.');
324        
325        // If it’s a profile information request:
326        if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) {
327            $this->logger->info('Handling a request to redeem an authorization code for profile information.');
328            
329            $bodyParams = $request->getParsedBody();
330
331            if (!isset($bodyParams['code'])) {
332                $this->logger->warning('The exchange request was missing the code parameter. Returning an error response.');
333                return new Response(400, ['content-type' => 'application/json'], json_encode([
334                    'error' => 'invalid_request',
335                    'error_description' => 'The code parameter was missing.'
336                ]));
337            }
338
339            // Attempt to internally exchange the provided auth code for an access token.
340            // We do this before anything else so that the auth code is invalidated as soon as the request starts,
341            // and the resulting access token is revoked if we encounter an error. This ends up providing a simpler
342            // and more flexible interface for TokenStorage implementors.
343            try {
344                // Call the token exchange method, passing in a callback which performs additional validation
345                // on the auth code before it gets exchanged.
346                $tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) {
347                    // Verify that all required parameters are included.
348                    $requiredParameters = ['client_id', 'redirect_uri', 'code_verifier'];
349                    $missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) {
350                        return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]);
351                    });
352                    if (!empty($missingRequiredParameters)) {
353                        $this->logger->warning('The exchange request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]);
354                        throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST, $request);
355                    }
356
357                    // Verify that it was issued for the same client_id and redirect_uri
358                    if ($authCode['client_id'] !== $bodyParams['client_id']
359                        || $authCode['redirect_uri'] !== $bodyParams['redirect_uri']) {
360                        $this->logger->error("The provided client_id and/or redirect_uri did not match those stored in the token.");
361                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
362                    }
363
364                    // Check that the supplied code_verifier hashes to the stored code_challenge
365                    // TODO: support method = plain as well as S256.
366                    if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) {
367                        $this->logger->error("The provided code_verifier did not hash to the stored code_challenge");
368                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
369                    }
370
371                    // Check that this token either grants at most the profile scope.
372                    $requestedScopes = explode(' ', $authCode['scope'] ?? '');
373                    if (!empty($requestedScopes) && $requestedScopes != ['profile']) {
374                        $this->logger->error("An exchange request for a token granting scopes other than “profile” was sent to the authorization endpoint.");
375                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
376                    }
377                });
378            } catch (IndieAuthException $e) {
379                // If an exception was thrown, return a corresponding error response.
380                return new Response(400, ['content-type' => 'application/json'], json_encode([
381                    'error' => $e->getInfo()['error'],
382                    'error_description' => $e->getMessage()
383                ]));
384            }
385
386            if (is_null($tokenData)) {
387                $this->logger->error('Attempting to exchange an auth code for a token resulted in null.', $bodyParams);
388                return new Response(400, ['content-type' => 'application/json'], json_encode([
389                    'error' => 'invalid_grant',
390                    'error_description' => 'The provided credentials were not valid.'
391                ]));
392            }
393
394            // TODO: return an error if the token doesn’t contain a me key.
395
396            // If everything checked out, return {"me": "https://example.com"} response
397            return new Response(200, [
398                'content-type' => 'application/json',
399                'cache-control' => 'no-store',
400            ], json_encode(array_filter($tokenData, function ($k) {
401                // Prevent codes exchanged at the authorization endpoint from returning any information other than
402                // me and profile.
403                return in_array($k, ['me', 'profile']);
404            }, ARRAY_FILTER_USE_KEY)));
405        }
406
407        // Because the special case above isn’t allowed to be CSRF-protected, we have to do some rather silly
408        // closure gymnastics here to selectively-CSRF-protect requests which do need it.
409        return $this->csrfMiddleware->process($request, new Middleware\ClosureRequestHandler(function (ServerRequestInterface $request) {
410            // Wrap the entire user-facing handler in a try/catch block which catches any exception, converts it
411            // to IndieAuthException if necessary, then passes it to $this->handleException() to be turned into a
412            // response.
413            try {
414                $queryParams = $request->getQueryParams();
415
416                /** @var ResponseInterface|null $clientIdResponse */
417                /** @var string|null $clientIdEffectiveUrl */
418                /** @var array|null $clientIdMf2 */
419                list($clientIdResponse, $clientIdEffectiveUrl, $clientIdMf2) = [null, null, null];
420
421                // If this is an authorization or approval request (allowing POST requests as well to accommodate 
422                // approval requests and custom auth form submission.
423                if (isIndieAuthAuthorizationRequest($request, ['get', 'post'])) {
424                    $this->logger->info('Handling an authorization request', ['method' => $request->getMethod()]);
425
426                    // Validate the Client ID.
427                    if (!isset($queryParams['client_id']) || false === filter_var($queryParams['client_id'], FILTER_VALIDATE_URL) || !isClientIdentifier($queryParams['client_id'])) {
428                        $this->logger->warning("The client_id provided in an authorization request was not valid.", $queryParams);
429                        throw IndieAuthException::create(IndieAuthException::INVALID_CLIENT_ID, $request);
430                    }
431
432                    // Validate the redirect URI.
433                    if (!isset($queryParams['redirect_uri']) || false === filter_var($queryParams['redirect_uri'], FILTER_VALIDATE_URL)) {
434                        $this->logger->warning("The client_id provided in an authorization request was not valid.", $queryParams);
435                        throw IndieAuthException::create(IndieAuthException::INVALID_REDIRECT_URI, $request);
436                    }
437
438                    // How most errors are handled depends on whether or not the request has a valid redirect_uri. In
439                    // order to know that, we need to also validate, fetch and parse the client_id.
440                    // If the request lacks a hash, or if the provided hash was invalid, perform the validation.
441                    if (!array_key_exists(self::HASH_QUERY_STRING_KEY, $queryParams) || !hash_equals(hashAuthorizationRequestParameters($request, $this->secret), $queryParams[self::HASH_QUERY_STRING_KEY])) {
442                        // All we need to know at this stage is whether the redirect_uri is valid. If it
443                        // sufficiently matches the client_id, we don’t (yet) need to fetch the client_id.
444                        if (!urlComponentsMatch($queryParams['client_id'], $queryParams['redirect_uri'], [PHP_URL_SCHEME, PHP_URL_HOST, PHP_URL_PORT])) {
445                            // If we do need to fetch the client_id, store the response and effective URL in variables
446                            // we defined earlier, so they’re available to the approval request code path, which additionally
447                            // needs to parse client_id for h-app markup.
448                            try {
449                                list($clientIdResponse, $clientIdEffectiveUrl) = call_user_func($this->httpGetWithEffectiveUrl, $queryParams['client_id']);
450                                $clientIdMf2 = Mf2\parse((string) $clientIdResponse->getBody(), $clientIdEffectiveUrl);
451                            } catch (ClientExceptionInterface | RequestExceptionInterface | NetworkExceptionInterface $e) {
452                                $this->logger->error("Caught an HTTP exception while trying to fetch the client_id. Returning an error response.", [
453                                    'client_id' => $queryParams['client_id'],
454                                    'exception' => $e->__toString()
455                                ]);
456
457                                throw IndieAuthException::create(IndieAuthException::HTTP_EXCEPTION_FETCHING_CLIENT_ID, $request, $e);
458                            } catch (Exception $e) {
459                                $this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [
460                                    'exception' => $e->__toString()
461                                ]);
462
463                                throw IndieAuthException::create(IndieAuthException::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID, $request, $e);
464                            }
465                            
466                            // Search for all link@rel=redirect_uri at the client_id.
467                            $clientIdRedirectUris = [];
468                            if (array_key_exists('redirect_uri', $clientIdMf2['rels'])) {
469                                $clientIdRedirectUris = array_merge($clientIdRedirectUris, $clientIdMf2['rels']['redirect_uri']);
470                            }
471                            
472                            foreach (HeaderParser::parse($clientIdResponse->getHeader('Link')) as $link) {
473                                if (array_key_exists('rel', $link) && mb_strpos(" {$link['rel']} ", " redirect_uri ") !== false) {
474                                    // Strip off the < > which surround the link URL for some reason.
475                                    $clientIdRedirectUris[] = substr($link[0], 1, strlen($link[0]) - 2);
476                                }
477                            }
478
479                            // If the authority of the redirect_uri does not match the client_id, or exactly match one of their redirect URLs, return an error.
480                            if (!in_array($queryParams['redirect_uri'], $clientIdRedirectUris)) {
481                                $this->logger->warning("The provided redirect_uri did not match either the client_id, nor the discovered redirect URIs.", [
482                                    'provided_redirect_uri' => $queryParams['redirect_uri'],
483                                    'provided_client_id' => $queryParams['client_id'],
484                                    'discovered_redirect_uris' => $clientIdRedirectUris
485                                ]);
486
487                                throw IndieAuthException::create(IndieAuthException::INVALID_REDIRECT_URI, $request);
488                            }
489                        }                        
490                    }
491
492                    // From now on, we can assume that redirect_uri is valid. Any IndieAuth-related errors should be
493                    // reported by redirecting to redirect_uri with error parameters.
494
495                    // Validate the state parameter.
496                    if (!isValidState($queryParams['state'])) {
497                        $this->logger->warning("The state provided in an authorization request was not valid.", $queryParams);
498                        throw IndieAuthException::create(IndieAuthException::INVALID_STATE, $request);
499                    }
500
501                    // Validate code_challenge parameter.
502                    if (!isValidCodeChallenge($queryParams['code_challenge'])) {
503                        $this->logger->warning("The code_challenge provided in an authorization request was not valid.", $queryParams);
504                        throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request);
505                    }
506
507                    // From now on, any redirect error responses should include the state parameter.
508                    // This is handled automatically in `handleException()` and is only noted here
509                    // for reference.
510
511                    // Validate the scope parameter, if provided.
512                    if (array_key_exists('scope', $queryParams) && !isValidScope($queryParams['scope'])) {
513                        $this->logger->warning("The scope provided in an authorization request was not valid.", $queryParams);
514                        throw IndieAuthException::create(IndieAuthException::INVALID_SCOPE, $request);
515                    }
516
517                    // Normalise the me parameter, if it exists.
518                    if (array_key_exists('me', $queryParams)) {
519                        $queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']);
520                        // If the me parameter is not a valid profile URL, ignore it.
521                        if (false === $queryParams['me'] || !isProfileUrl($queryParams['me'])) {
522                            $queryParams['me'] = null;
523                        }
524                    }
525
526                    // Build a URL containing the indieauth authorization request parameters, hashing them
527                    // to protect them from being changed.
528                    // Make a hash of the protected indieauth-specific parameters.
529                    $hash = hashAuthorizationRequestParameters($request, $this->secret);
530                    // Operate on a copy of $queryParams, otherwise requests will always have a valid hash!
531                    $redirectQueryParams = $queryParams;
532                    $redirectQueryParams[self::HASH_QUERY_STRING_KEY] = $hash;
533                    $authenticationRedirect = $request->getUri()->withQuery(buildQueryString($redirectQueryParams))->__toString();
534                    
535                    // User-facing requests always start by calling the authentication request callback.
536                    $this->logger->info('Calling handle_authentication_request callback');
537                    $authenticationResult = call_user_func($this->handleAuthenticationRequestCallback, $request, $authenticationRedirect, $queryParams['me'] ?? null);
538                    
539                    // If the authentication handler returned a Response, return that as-is.
540                    if ($authenticationResult instanceof ResponseInterface) {
541                        return $authenticationResult;
542                    } elseif (is_array($authenticationResult)) {
543                        // Check the resulting array for errors.
544                        if (!array_key_exists('me', $authenticationResult)) {
545                            $this->logger->error('The handle_authentication_request callback returned an array with no me key.', ['array' => $authenticationResult]);
546                            throw IndieAuthException::create(IndieAuthException::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM, $request);
547                        }
548
549                        // If this is a POST request sent from the authorization (i.e. scope-choosing) form:
550                        if (isAuthorizationApprovalRequest($request)) {
551                            // Authorization approval requests MUST include a hash protecting the sensitive IndieAuth
552                            // authorization request parameters from being changed, e.g. by a malicious script which
553                            // found its way onto the authorization form.
554                            if (!array_key_exists(self::HASH_QUERY_STRING_KEY, $queryParams)) {
555                                $this->logger->warning("An authorization approval request did not have a " . self::HASH_QUERY_STRING_KEY . " parameter.");
556                                throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH, $request);
557                            }
558
559                            $expectedHash = hashAuthorizationRequestParameters($request, $this->secret);
560                            if (!hash_equals($expectedHash, $queryParams[self::HASH_QUERY_STRING_KEY])) {
561                                $this->logger->warning("The hash provided in the URL was invalid!", [
562                                    'expected' => $expectedHash,
563                                    'actual' => $queryParams[self::HASH_QUERY_STRING_KEY]
564                                ]);
565                                throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH, $request);
566                            }
567                            
568                            // Assemble the data for the authorization code, store it somewhere persistent.
569                            $code = array_merge($authenticationResult, [
570                                'client_id' => $queryParams['client_id'],
571                                'redirect_uri' => $queryParams['redirect_uri'],
572                                'state' => $queryParams['state'],
573                                'code_challenge' => $queryParams['code_challenge'],
574                                'code_challenge_method' => $queryParams['code_challenge_method'],
575                                'requested_scope' => $queryParams['scope'] ?? '',
576                            ]);
577
578                            // Pass it to the auth code customisation callback.
579                            $code = $this->authorizationForm->transformAuthorizationCode($request, $code);
580
581                            // Store the authorization code.
582                            $authCode = $this->tokenStorage->createAuthCode($code);
583                            if (is_null($authCode)) {
584                                // If saving the authorization code failed silently, there isn’t much we can do about it,
585                                // but should at least log and return an error.
586                                $this->logger->error("Saving the authorization code failed and returned false without raising an exception.");
587                                throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request);
588                            }
589                            
590                            // Return a redirect to the client app.
591                            return new Response(302, [
592                                'Location' => appendQueryParams($queryParams['redirect_uri'], [
593                                    'code' => $authCode,
594                                    'state' => $code['state']
595                                ]),
596                                'Cache-control' => 'no-cache'
597                            ]);
598                        }
599
600                        // Otherwise, the user is authenticated and needs to authorize the client app + choose scopes.
601
602                        // Fetch the client_id URL to find information about the client to present to the user.
603                        // TODO: in order to comply with https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1,
604                        // it may be necessary to do this before returning any other kind of error response, as, per
605                        // the spec, errors should only be shown to the user if the client_id and redirect_uri parameters
606                        // are missing or invalid. Otherwise, they should be sent back to the client with an error
607                        // redirect response.
608                        if (is_null($clientIdResponse) || is_null($clientIdEffectiveUrl) || is_null($clientIdMf2)) {
609                            try {
610                                /** @var ResponseInterface $clientIdResponse */
611                                list($clientIdResponse, $clientIdEffectiveUrl) = call_user_func($this->httpGetWithEffectiveUrl, $queryParams['client_id']);
612                                $clientIdMf2 = Mf2\parse((string) $clientIdResponse->getBody(), $clientIdEffectiveUrl);
613                            } catch (ClientExceptionInterface | RequestExceptionInterface | NetworkExceptionInterface $e) {
614                                $this->logger->error("Caught an HTTP exception while trying to fetch the client_id. Returning an error response.", [
615                                    'client_id' => $queryParams['client_id'],
616                                    'exception' => $e->__toString()
617                                ]);
618                                
619                                // At this point in the flow, we’ve already guaranteed that the redirect_uri is valid,
620                                // so in theory we should report these errors by redirecting there.
621                                throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request, $e);
622                            } catch (Exception $e) {
623                                $this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [
624                                    'exception' => $e->__toString()
625                                ]);
626    
627                                throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request, $e);
628                            }
629                        }
630                        
631                        // Search for an h-app with u-url matching the client_id.
632                        $clientHApps = M\findMicroformatsByProperty(M\findMicroformatsByType($clientIdMf2, 'h-app'), 'url', $queryParams['client_id']);
633                        $clientHApp = empty($clientHApps) ? null : $clientHApps[0];
634
635                        // Present the authorization UI.
636                        return $this->authorizationForm->showForm($request, $authenticationResult, $authenticationRedirect, $clientHApp)
637                                ->withAddedHeader('Cache-control', 'no-cache');
638                    }
639                }
640
641                // If the request isn’t an IndieAuth Authorization or Code-redeeming request, it’s either an invalid
642                // request or something to do with a custom auth handler (e.g. sending a one-time code in an email.)
643                $nonIndieAuthRequestResult = call_user_func($this->handleNonIndieAuthRequest, $request);
644                if ($nonIndieAuthRequestResult instanceof ResponseInterface) {
645                    return $nonIndieAuthRequestResult;
646                } else {
647                    // In this code path we have not validated the redirect_uri, so show a regular error page
648                    // rather than returning a redirect error.
649                    throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR, $request);
650                }
651            } catch (IndieAuthException $e) {
652                // All IndieAuthExceptions will already have been logged.
653                return $this->handleException($e);
654            } catch (Exception $e) {
655                // Unknown exceptions will not have been logged; do so now.
656                $this->logger->error("Caught unknown exception: {$e}");
657                return $this->handleException(IndieAuthException::create(0, $request, $e));
658            }
659        }));    
660    }
661
662    /**
663     * Handle Token Endpoint Request
664     * 
665     * Handles requests to the IndieAuth token endpoint. The logical flow can be summarised as follows:
666     * 
667     * * Check that the request is a code redeeming request. Return an error if not.
668     * * Ensure that all required parameters are present. Return an error if not.
669     * * Attempt to exchange the `code` parameter for an access token. Return an error if it fails.
670     * * Make sure the client_id and redirect_uri request parameters match those stored in the auth code. If not, revoke the access token and return an error.
671     * * Make sure the provided code_verifier hashes to the code_challenge stored in the auth code. If not, revoke the access token and return an error.
672     * * Make sure the granted scope stored in the auth code is not empty. If it is, revoke the access token and return an error.
673     * * Otherwise, return a success response containing information about the issued access token.
674     * 
675     * This method must NOT be CSRF-protected as it accepts external requests from client apps.
676     * 
677     * @param ServerRequestInterface $request
678     * @return ResponseInterface
679     */
680    public function handleTokenEndpointRequest(ServerRequestInterface $request): ResponseInterface {
681        if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) {
682            $this->logger->info('Handling a request to redeem an authorization code for profile information.');
683            
684            $bodyParams = $request->getParsedBody();
685
686            if (!isset($bodyParams['code'])) {
687                $this->logger->warning('The exchange request was missing the code parameter. Returning an error response.');
688                return new Response(400, ['content-type' => 'application/json'], json_encode([
689                    'error' => 'invalid_request',
690                    'error_description' => 'The code parameter was missing.'
691                ]));
692            }
693
694            // Attempt to internally exchange the provided auth code for an access token.
695            // We do this before anything else so that the auth code is invalidated as soon as the request starts,
696            // and the resulting access token is revoked if we encounter an error. This ends up providing a simpler
697            // and more flexible interface for TokenStorage implementors.
698            try {
699                // Call the token exchange method, passing in a callback which performs additional validation
700                // on the auth code before it gets exchanged.
701                $tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) {
702                    // Verify that all required parameters are included.
703                    $requiredParameters = ['client_id', 'redirect_uri', 'code_verifier'];
704                    $missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) {
705                        return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]);
706                    });
707                    if (!empty($missingRequiredParameters)) {
708                        $this->logger->warning('The exchange request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]);
709                        throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST, $request);
710                    }
711
712                    // Verify that it was issued for the same client_id and redirect_uri
713                    if ($authCode['client_id'] !== $bodyParams['client_id']
714                        || $authCode['redirect_uri'] !== $bodyParams['redirect_uri']) {
715                        $this->logger->error("The provided client_id and/or redirect_uri did not match those stored in the token.");
716                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
717                    }
718
719                    // Check that the supplied code_verifier hashes to the stored code_challenge
720                    // TODO: support method = plain as well as S256.
721                    if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) {
722                        $this->logger->error("The provided code_verifier did not hash to the stored code_challenge");
723                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
724                    }
725
726                    // Check that scope is not empty.
727                    if (empty($authCode['scope'])) {
728                        $this->logger->error("An exchange request for a token with an empty scope was sent to the token endpoint.");
729                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
730                    }
731                });
732            } catch (IndieAuthException $e) {
733                // If an exception was thrown, return a corresponding error response.
734                return new Response(400, ['content-type' => 'application/json'], json_encode([
735                    'error' => $e->getInfo()['error'],
736                    'error_description' => $e->getMessage()
737                ]));
738            }
739
740            if (is_null($tokenData)) {
741                $this->logger->error('Attempting to exchange an auth code for a token resulted in null.', $bodyParams);
742                return new Response(400, ['content-type' => 'application/json'], json_encode([
743                    'error' => 'invalid_grant',
744                    'error_description' => 'The provided credentials were not valid.'
745                ]));
746            }
747
748            // TODO: return an error if the token doesn’t contain a me key.
749
750            // If everything checked out, return {"me": "https://example.com"} response
751            return new Response(200, [
752                'content-type' => 'application/json',
753                'cache-control' => 'no-store',
754            ], json_encode(array_merge([
755                // Ensure that the token_type key is present, if tokenStorage doesn’t include it.
756                'token_type' => 'Bearer'
757            ], array_filter($tokenData, function ($k) {
758                // We should be able to trust the return data from tokenStorage, but there’s no harm in
759                // preventing code_challenges from leaking, per OAuth2.
760                return !in_array($k, ['code_challenge', 'code_challenge_method']);
761            }, ARRAY_FILTER_USE_KEY))));
762        }
763
764        return new Response(400, ['content-type' => 'application/json'], json_encode([
765            'error' => 'invalid_request',
766            'error_description' => 'Request to token endpoint was not a valid code exchange request.'
767        ]));
768    }
769
770    /**
771     * Handle Exception
772     * 
773     * Turns an instance of `IndieAuthException` into an appropriate instance of `ResponseInterface`.
774     */
775    protected function handleException(IndieAuthException $exception): ResponseInterface {
776        $exceptionData = $exception->getInfo();
777
778        if ($exceptionData['statusCode'] == 302) {
779            // This exception is handled by redirecting to the redirect_uri with error parameters.
780            $redirectQueryParams = [
781                'error' => $exceptionData['error'] ?? 'invalid_request',
782                'error_description' => (string) $exception
783            ];
784
785            // If the state parameter was valid, include it in the error redirect.
786            if ($exception->getCode() !== IndieAuthException::INVALID_STATE) {
787                $redirectQueryParams['state'] = $exception->getRequest()->getQueryParams()['state'];
788            }
789
790            return new Response($exceptionData['statusCode'], [
791                'Location' => appendQueryParams((string) $exception->getRequest()->getQueryParams()['redirect_uri'], $redirectQueryParams)
792            ]);
793        } else {
794            // This exception should be shown to the user.
795            return new Response($exception->getStatusCode(), ['content-type' => 'text/html'], renderTemplate($this->exceptionTemplatePath, [
796                'request' => $exception->getRequest(),
797                'exception' => $exception
798            ]));
799        }
800    }
801}