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