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