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